diff --git a/apps/local-server/src/constellation-routes.ts b/apps/local-server/src/constellation-routes.ts index 0176706..5478dba 100644 --- a/apps/local-server/src/constellation-routes.ts +++ b/apps/local-server/src/constellation-routes.ts @@ -362,6 +362,32 @@ export function registerConstellationRoutes( }, ); + server.post( + "/constellation/reset", + { + preValidation: strictBody(envelope, [ + "worldInstanceId", + "expectedRevision", + "idempotencyKey", + ]), + schema: { + tags, + summary: "End the current roster and start an empty World", + body: identityBody, + }, + }, + async (request, reply) => { + try { + return envelope.success( + request, + await service.reset(request.body as MutationBody), + ); + } catch (error) { + return fail(error, request, reply, envelope); + } + }, + ); + server.post( "/constellation/end", { diff --git a/apps/local-server/src/constellation-service.ts b/apps/local-server/src/constellation-service.ts index 732b40a..e05eaff 100644 --- a/apps/local-server/src/constellation-service.ts +++ b/apps/local-server/src/constellation-service.ts @@ -654,105 +654,143 @@ export class ConstellationService { } end(request: MutationIdentity): Promise { + return this.#enqueue(() => this.#end(request)); + } + + reset(request: MutationIdentity): Promise { return this.#enqueue(async () => { validateMutation(request); - const payload = this.#requirePayload(request.worldInstanceId); const inputHash = sha256(canonical(request)); - const existing = payload.idempotency.find( - (record) => record.key === request.idempotencyKey, + const prior = this.#payload?.idempotency.find( + (record) => + record.key === request.idempotencyKey && + record.operation === "world-reset", ); - if (existing) { - if ( - existing.operation !== "world-end" || - existing.inputHash !== inputHash - ) + if (prior) { + if (prior.inputHash !== inputHash) throw new ConstellationServiceError( "conflict", - "Idempotency identity was reused with different input", + "Reset identity was reused with different input", ); - if (existing.status === "completed" && existing.result) - return structuredClone(existing.result); - } else { - this.#assertRevision(request.expectedRevision); - this.#setIdempotency({ - key: request.idempotencyKey, - operation: "world-end", - inputHash, - status: "pending", - result: null, - }); - this.#payload = { - ...this.#requirePayload(request.worldInstanceId), - projection: ConstellationProjectionSchema.parse({ - ...payload.projection, - lifecycle: "ending", - revision: payload.projection.revision + 1, - entryReady: false, - truth: "current", - }), - }; - await this.#persist(); + if (prior.result) return structuredClone(prior.result); } - - const current = this.#requirePayload(request.worldInstanceId); - for (const agent of current.projection.agents) { - const prior = current.terminalOutcomes.find( - (outcome) => outcome.rosterId === agent.rosterId, - ); - if (agent.sessionOwnership === "operator-persistent") { - if (!prior) - await this.#recordOutcome({ - rosterId: agent.rosterId, - status: "skipped-operator-persistent", - }); - continue; - } - if (prior?.status === "ended") continue; - try { - await this.#lifecycle.endWorldSession( - agent.worldSessionId, - request.worldInstanceId, - ); - await this.#recordOutcome({ - rosterId: agent.rosterId, - status: "ended", - }); - } catch { - await this.#recordOutcome({ - rosterId: agent.rosterId, - status: "failed", - }); - throw new ConstellationServiceError( - "upstream", - "A World-owned session could not be ended", - ); - } - } - - const finishing = this.#requirePayload(request.worldInstanceId); + await this.#end(request); this.#payload = { - ...finishing, - projection: ConstellationProjectionSchema.parse({ - ...finishing.projection, - lifecycle: "ended", - revision: finishing.projection.revision + 1, - entryReady: false, - truth: "current", - }), + projection: emptyProjection(randomUUID()), + terminalOutcomes: [], + idempotency: [], }; const result = this.#currentState(); this.#setIdempotency({ key: request.idempotencyKey, - operation: "world-end", + operation: "world-reset", inputHash, status: "completed", result, }); await this.#persist(); - return this.#currentState(); + return result; }); } + async #end(request: MutationIdentity): Promise { + validateMutation(request); + const payload = this.#requirePayload(request.worldInstanceId); + const inputHash = sha256(canonical(request)); + const existing = payload.idempotency.find( + (record) => record.key === request.idempotencyKey, + ); + if (existing) { + if ( + existing.operation !== "world-end" || + existing.inputHash !== inputHash + ) + throw new ConstellationServiceError( + "conflict", + "Idempotency identity was reused with different input", + ); + if (existing.status === "completed" && existing.result) + return structuredClone(existing.result); + } else { + this.#assertRevision(request.expectedRevision); + this.#setIdempotency({ + key: request.idempotencyKey, + operation: "world-end", + inputHash, + status: "pending", + result: null, + }); + this.#payload = { + ...this.#requirePayload(request.worldInstanceId), + projection: ConstellationProjectionSchema.parse({ + ...payload.projection, + lifecycle: "ending", + revision: payload.projection.revision + 1, + entryReady: false, + truth: "current", + }), + }; + await this.#persist(); + } + + const current = this.#requirePayload(request.worldInstanceId); + for (const agent of current.projection.agents) { + const prior = current.terminalOutcomes.find( + (outcome) => outcome.rosterId === agent.rosterId, + ); + if (agent.sessionOwnership === "operator-persistent") { + if (!prior) + await this.#recordOutcome({ + rosterId: agent.rosterId, + status: "skipped-operator-persistent", + }); + continue; + } + if (prior?.status === "ended") continue; + try { + await this.#lifecycle.endWorldSession( + agent.worldSessionId, + request.worldInstanceId, + ); + await this.#recordOutcome({ + rosterId: agent.rosterId, + status: "ended", + }); + } catch { + await this.#recordOutcome({ + rosterId: agent.rosterId, + status: "failed", + }); + throw new ConstellationServiceError( + "upstream", + "A World-owned session could not be ended", + ); + } + } + + const finishing = this.#requirePayload(request.worldInstanceId); + this.#payload = { + ...finishing, + projection: ConstellationProjectionSchema.parse({ + ...finishing.projection, + lifecycle: "ended", + revision: finishing.projection.revision + 1, + entryReady: false, + truth: "current", + }), + }; + const result = this.#currentState(); + this.#setIdempotency({ + key: request.idempotencyKey, + operation: "world-end", + inputHash, + status: "completed", + result, + }); + await this.#persist(); + return this.#currentState(); + } + #mutate( operation: string, request: MutationIdentity, diff --git a/apps/local-server/test/phase19-constellation-api.test.ts b/apps/local-server/test/phase19-constellation-api.test.ts index 8205a83..50a02ae 100644 --- a/apps/local-server/test/phase19-constellation-api.test.ts +++ b/apps/local-server/test/phase19-constellation-api.test.ts @@ -74,6 +74,117 @@ async function serverWith( } describe("Phase 19 constellation API", () => { + it("resets a full roster into a durable fresh World and permits the same agent choices", async () => { + const endWorldSession = vi.fn(async () => undefined); + const stateDirectory = directory(); + const { server, service } = await serverWith( + lifecyclePort({ endWorldSession }), + stateDirectory, + ); + const adapters = ["hermes", "openclaw", "codex", "claude-code"] as const; + for (const [index, adapter] of adapters.entries()) { + expect( + ( + await server.inject({ + method: "POST", + url: "/constellation/agents", + payload: agent(index + 1, adapter), + }) + ).statusCode, + ).toBe(201); + } + const payload = identity(4, "reset-full-world"); + const reset = await server.inject({ + method: "POST", + url: "/constellation/reset", + payload, + }); + expect(reset.statusCode).toBe(200); + const fresh = reset.json().data; + expect(fresh.projection).toMatchObject({ + agents: [], + revision: 0, + lifecycle: "assembling", + entryReady: false, + }); + expect(fresh.projection.worldInstanceId).not.toBe("world-api"); + expect(endWorldSession.mock.calls).toEqual([ + ["api-world-session-2", "world-api"], + ["api-world-session-3", "world-api"], + ["api-world-session-4", "world-api"], + ]); + for (const [index, adapter] of adapters.entries()) { + const input = agent(index + 1, adapter); + expect( + ( + await server.inject({ + method: "POST", + url: "/constellation/agents", + payload: { + ...input, + worldInstanceId: fresh.projection.worldInstanceId, + agent: { + ...input.agent, + worldSessionId: `replacement-${index}`, + nativeRootSessionRef: `replacement-native-${index}`, + }, + }, + }) + ).statusCode, + ).toBe(201); + } + const replay = await server.inject({ + method: "POST", + url: "/constellation/reset", + payload, + }); + expect(replay.statusCode).toBe(200); + expect(replay.json().data).toEqual(fresh); + expect(service.current().projection?.agents).toHaveLength(4); + expect(endWorldSession).toHaveBeenCalledTimes(3); + const reopened = await ConstellationService.open({ + directory: stateDirectory, + lifecycle: lifecyclePort(), + }); + expect(reopened.current().projection?.worldInstanceId).toBe( + fresh.projection.worldInstanceId, + ); + expect(reopened.current().projection?.agents).toHaveLength(4); + }); + + it("keeps failed reset retryable without opening a replacement World", async () => { + const endWorldSession = vi + .fn() + .mockRejectedValueOnce(new Error("busy")) + .mockResolvedValue(undefined); + const { server, service } = await serverWith( + lifecyclePort({ endWorldSession }), + ); + await server.inject({ + method: "POST", + url: "/constellation/agents", + payload: agent(1, "codex"), + }); + const payload = identity(1, "reset-retry"); + const failed = await server.inject({ + method: "POST", + url: "/constellation/reset", + payload, + }); + expect(failed.statusCode).toBe(502); + expect(service.current().projection).toMatchObject({ + worldInstanceId: "world-api", + lifecycle: "ending", + }); + const retried = await server.inject({ + method: "POST", + url: "/constellation/reset", + payload, + }); + expect(retried.statusCode).toBe(200); + expect(retried.json().data.projection.agents).toEqual([]); + expect(endWorldSession).toHaveBeenCalledTimes(2); + }); it("exposes all six routes with normal envelopes and side-effect-free repeated GET", async () => { const validateBinding = vi.fn(async (binding) => ({ ...binding, diff --git a/apps/web/src/sessions/session-client.ts b/apps/web/src/sessions/session-client.ts index da87a8c..f40ac92 100644 --- a/apps/web/src/sessions/session-client.ts +++ b/apps/web/src/sessions/session-client.ts @@ -625,6 +625,12 @@ export class AgentSessionClient { ); } + resetConstellation( + input: ConstellationMutation, + ): Promise { + return this.post("/api/constellation/reset", input); + } + endConstellation(input: ConstellationMutation): Promise { return this.post("/api/constellation/end", input); } diff --git a/apps/web/src/world-entry/WorldEntryExperience.tsx b/apps/web/src/world-entry/WorldEntryExperience.tsx index 7a30cd1..81cfb9a 100644 --- a/apps/web/src/world-entry/WorldEntryExperience.tsx +++ b/apps/web/src/world-entry/WorldEntryExperience.tsx @@ -368,6 +368,13 @@ export function WorldEntryExperience({ const [agentAvatar, setAgentAvatar] = useState(null); const [constellation, setConstellation] = useState(null); + const [resetPending, setResetPending] = useState(false); + const resetInFlight = useRef(false); + const resetRequest = useRef<{ + worldInstanceId: string; + expectedRevision: number; + idempotencyKey: string; + } | null>(null); const [constellationBusyRosterId, setConstellationBusyRosterId] = useState< string | null >(null); @@ -2113,6 +2120,58 @@ export function WorldEntryExperience({ dispatch({ type: "LEAVE_WORLD", destination }); }; + const resetWorldSession = async () => { + if (resetInFlight.current) return; + if (chatBusy || processingChat.current) { + setError( + "Wait for the current agent turn to finish, then retry Reset Session.", + ); + return; + } + resetInFlight.current = true; + setResetPending(true); + setError(""); + try { + if (!resetRequest.current) { + const current = (await client.currentConstellation()).projection; + resetRequest.current = { + worldInstanceId: current.worldInstanceId, + expectedRevision: current.revision, + idempotencyKey: `world-reset-${crypto.randomUUID()}`, + }; + } + const next = await client.resetSession( + resetRequest.current, + state.sessionMode === "single" ? session : null, + ); + setConstellation(next.projection); + setConstellationBusyRosterId(null); + setAcceptedAgentAvatars({}); + setSelectedRecipientId(null); + setSelectedConnectionId(""); + setRosterMovementRequests({}); + setRosterMovementControls({}); + setRosterWorkFocus({}); + setAgentWorkFocus(null); + processedRosterMovementOutcomes.current.clear(); + setAvatarEditingAgent(null); + setChangeAgentWork(null); + setWheelTaskOpen(false); + setWorkbenchOpen(false); + setWorldViewOpen(false); + setRepositoryReadiness("idle"); + resetRequest.current = null; + leaveWorld("session_select"); + } catch { + setError( + "Session reset could not finish. Wait for active agent work to finish, then retry Reset Session. Your current session has not been replaced.", + ); + } finally { + resetInFlight.current = false; + setResetPending(false); + } + }; + const inWorld = state.step === "world_entering" || state.step === "world_blank" || @@ -3397,7 +3456,7 @@ export function WorldEntryExperience({ preferences={preferences} onPreferences={updatePreferences} onLogout={() => leaveWorld("session_select")} - onResetSession={() => leaveWorld("session_select")} + onResetSession={() => void resetWorldSession()} agents={worldAgentAvatars.map((a) => ({ rosterId: a.rosterId, name: a.name, @@ -3423,6 +3482,18 @@ export function WorldEntryExperience({ }} /> ) : null} + {resetPending ? ( +
+ +
+ ) : null} {repositoryLoading ? (
reset_session_

Reset current World session?

- This clears only this browser’s World attachment and presentation. - Your saved identity, avatars, and native agent history remain. + This ends the current World-owned agent sessions and releases all + agent seats so you can choose a new single-agent or multi-agent + session. Saved connections, identity, avatars, and native history + remain. Operator-persistent agents are not stopped.