diff --git a/server/src/modules/computers/application/service.test.ts b/server/src/modules/computers/application/service.test.ts index 356766d035..3057464183 100644 --- a/server/src/modules/computers/application/service.test.ts +++ b/server/src/modules/computers/application/service.test.ts @@ -191,6 +191,41 @@ describe("computer ownership", () => { (await f.repository.get(f.scope)).ledger.action?.providerStopId, ).toBe("stop_1"); }); + it("replaces a superseded stop while preserving the admission fence", async () => { + const f = fixture(); + await f.attach(); + await f.service.reconcile(); + vi.mocked(f.backend.stopStatus).mockResolvedValueOnce({ id: "stop_1", status: "superseded" }); + vi.mocked(f.backend.stop).mockResolvedValueOnce({ id: "stop_2", status: "pending" }); + await f.service.reconcile(); + expect((await f.repository.get(f.scope)).ledger.action?.providerStopId).toBe("stop_2"); + await expect(f.admit()).rejects.toMatchObject({ code: "conflict" }); + vi.mocked(f.backend.stopStatus).mockResolvedValueOnce({ id: "stop_2", status: "completed" }); + await f.service.reconcile(); + expect(f.backend.stopStatus).toHaveBeenLastCalledWith(expect.anything(), "stop_2"); + await expect(f.admit()).resolves.toHaveProperty("owner"); + }); + it("adopts a newer pending stop without issuing a duplicate operation", async () => { + const f = fixture(); + await f.attach(); + await f.service.reconcile(); + vi.mocked(f.backend.stopStatus).mockResolvedValueOnce({ id: "stop_1", status: "superseded" }); + vi.mocked(f.backend.inspect).mockResolvedValueOnce({ state: "ready", snapshots: true, stop: { id: "stop_2", status: "failing" } }); + await f.service.reconcile(); + expect((await f.repository.get(f.scope)).ledger.action?.providerStopId).toBe("stop_2"); + expect(f.backend.stop).toHaveBeenCalledOnce(); + await expect(f.admit()).rejects.toMatchObject({ code: "conflict" }); + }); + it("settles a superseded stop only after physical archival proof", async () => { + const f = fixture(); + await f.attach(); + await f.service.detach(f.scope); + vi.mocked(f.backend.stopStatus).mockResolvedValueOnce({ id: "stop_1", status: "superseded" }); + vi.mocked(f.backend.inspect).mockResolvedValueOnce({ state: "archived", snapshots: true, stop: null }); + await f.service.reconcile(); + expect((await f.repository.get(f.scope)).ledger).toMatchObject({ status: "detached", action: null }); + expect(f.backend.stop).toHaveBeenCalledOnce(); + }); it("viewer renewals cannot extend the absolute warm deadline or use another user", async () => { const f = fixture(); await f.attach(); diff --git a/server/src/modules/computers/application/service.ts b/server/src/modules/computers/application/service.ts index 39945961d4..3e6a226420 100644 --- a/server/src/modules/computers/application/service.ts +++ b/server/src/modules/computers/application/service.ts @@ -885,9 +885,23 @@ finally: if (record.ledger.action) { const action = record.ledger.action; // A lost stop response is retried only while admission is fenced. Boat returns its same pending ID. - const stop = action.providerStopId + let stop = action.providerStopId ? await backend.stopStatus(record, action.providerStopId) : await backend.stop(record); + if (stop.status === "superseded") { + // A provider-side resume can supersede the recorded stop. Keep admission + // fenced until current physical state proves stopped or a replacement + // normal snapshot operation completes; never force or delete the host. + const current = await backend.inspect(record); + if (["stopped", "archived"].includes(current.state)) { + stop = { id: stop.id, status: "completed" }; + } else if (current.stop && current.stop.id !== stop.id && + ["pending", "failing"].includes(current.stop.status)) { + stop = current.stop; + } else { + stop = await backend.stop(record); + } + } await repository.update(record, (current) => { if (current.ledger.action?.id !== action.id) return; current.ledger.action.providerStopId = stop.id;