diff --git a/Sources/CodexReview/Settings/CodexReviewSettingsService.swift b/Sources/CodexReview/Settings/CodexReviewSettingsService.swift index 20ccd4f..a4c7fd5 100644 --- a/Sources/CodexReview/Settings/CodexReviewSettingsService.swift +++ b/Sources/CodexReview/Settings/CodexReviewSettingsService.swift @@ -68,7 +68,10 @@ package final class CodexReviewSettingsService { private enum RuntimeCutoverPhase { case active(epoch: UInt64, lastConsumedTokenID: UUID?) case draining(RuntimeCutoverToken) - case awaitingCommit(RuntimeCutoverToken) + case awaitingCommit( + RuntimeCutoverToken, + priorErrorMessage: String? + ) case awaitingRecovery( committedEpoch: UInt64, deferredEpoch: UInt64, @@ -92,7 +95,7 @@ package final class CodexReviewSettingsService { switch self { case .active(let epoch, _): epoch - case .draining(let token), .awaitingCommit(let token): + case .draining(let token), .awaitingCommit(let token, _): token.targetEpoch case .awaitingRecovery(_, let deferredEpoch, _): deferredEpoch @@ -192,7 +195,10 @@ package final class CodexReviewSettingsService { guard case .draining(token) = cutoverPhase else { throw RuntimeCutoverError.staleToken } - cutoverPhase = .awaitingCommit(token) + cutoverPhase = .awaitingCommit( + token, + priorErrorMessage: settingsStore.lastErrorMessage + ) case .awaitingRecovery(let committedEpoch, let deferredEpoch, _): token = .init( @@ -201,7 +207,10 @@ package final class CodexReviewSettingsService { sourceEpoch: committedEpoch, targetEpoch: deferredEpoch ) - cutoverPhase = .awaitingCommit(token) + cutoverPhase = .awaitingCommit( + token, + priorErrorMessage: settingsStore.lastErrorMessage + ) case .draining, .awaitingCommit: throw RuntimeCutoverError.cutoverAlreadyInProgress @@ -215,7 +224,7 @@ package final class CodexReviewSettingsService { token: RuntimeCutoverToken, snapshot: CodexReviewSettings.Snapshot ) async throws { - try requireCurrentCutoverToken(token) + _ = try requireCurrentCutoverToken(token) guard let settingsStore else { throw RuntimeCutoverError.settingsStoreUnavailable } @@ -238,7 +247,7 @@ package final class CodexReviewSettingsService { token: RuntimeCutoverToken, message: String ) throws { - try requireCurrentCutoverToken(token) + _ = try requireCurrentCutoverToken(token) guard let settingsStore else { throw RuntimeCutoverError.settingsStoreUnavailable } @@ -257,6 +266,37 @@ package final class CodexReviewSettingsService { ) } + package func cancelRuntimeCutover( + token: RuntimeCutoverToken + ) throws { + let priorErrorMessage = try requireCurrentCutoverToken(token) + guard let settingsStore else { + throw RuntimeCutoverError.settingsStoreUnavailable + } + guard token.targetEpoch < UInt64.max else { + throw RuntimeCutoverError.epochExhausted + } + + let deferredEpoch = token.targetEpoch + 1 + queuedIntents = queuedIntents.map { queuedIntent in + guard queuedIntent.epoch == token.targetEpoch else { + return queuedIntent + } + return .init( + epoch: deferredEpoch, + intent: queuedIntent.intent, + requiresCatalogRevalidation: queuedIntent.requiresCatalogRevalidation + ) + } + cutoverPhase = .awaitingRecovery( + committedEpoch: token.sourceEpoch, + deferredEpoch: deferredEpoch, + lastConsumedTokenID: token.id + ) + replayQueuedSelectionIntents(for: deferredEpoch, settingsStore: settingsStore) + settingsStore.finishLoading(errorMessage: priorErrorMessage) + } + package func refreshIfRunning(serverState: CodexReviewServerState) async { guard case .running = serverState else { return @@ -557,18 +597,19 @@ package final class CodexReviewSettingsService { private func requireCurrentCutoverToken( _ token: RuntimeCutoverToken - ) throws { + ) throws -> String? { guard token.ownerID == cutoverOwnerID else { throw RuntimeCutoverError.foreignToken } if cutoverPhase.consumedTokenID() == token.id { throw RuntimeCutoverError.tokenAlreadyConsumed } - guard case .awaitingCommit(let currentToken) = cutoverPhase, + guard case .awaitingCommit(let currentToken, let priorErrorMessage) = cutoverPhase, currentToken == token else { throw RuntimeCutoverError.staleToken } + return priorErrorMessage } private func persistSelection( diff --git a/Tests/CodexReviewTests/CodexReviewSettingsRuntimeCutoverTests.swift b/Tests/CodexReviewTests/CodexReviewSettingsRuntimeCutoverTests.swift index 86ab6c1..fb10b13 100644 --- a/Tests/CodexReviewTests/CodexReviewSettingsRuntimeCutoverTests.swift +++ b/Tests/CodexReviewTests/CodexReviewSettingsRuntimeCutoverTests.swift @@ -90,6 +90,104 @@ struct CodexReviewSettingsRuntimeCutoverTests { #expect(store.settings.lastErrorMessage == "Rejected after publication.") } + @Test func intentionalCancellationDuringPreparedCutoverDoesNotSurfaceAnError() async throws { + let initial = settingsSnapshot(model: "initial-model") + let backend = FakeCodexReviewBackend(settings: backendSnapshot(initial)) + let store = makeStore(initial: initial, backend: backend) + let updateGate = AsyncGate() + await backend.holdNextSettingsUpdate(with: updateGate) + + let admittedWrite = Task { @MainActor in + await store.updateSettingsModel("before-cutover") + } + await backend.waitForSettingsUpdate() + let cutover = Task { @MainActor in + try await store.settingsService.beginRuntimeCutover() + } + try await waitForCutoverStatus(.draining, service: store.settingsService) + + await updateGate.open() + let token = try await cutover.value + await admittedWrite.value + try store.settingsService.cancelRuntimeCutover(token: token) + + #expect(store.settingsService.runtimeCutoverStatus == .awaitingRecovery) + #expect(store.settings.isLoading == false) + #expect(store.settings.lastErrorMessage == nil) + #expect(await backend.settingsSnapshot().model == "before-cutover") + } + + @Test func intentionalCancellationPreservesAnUnrelatedSettingsError() async throws { + let initial = settingsSnapshot(model: "initial-model") + let backend = FakeCodexReviewBackend(settings: backendSnapshot(initial)) + let store = makeStore(initial: initial, backend: backend) + await backend.failNextSettingsUpdate(message: "Existing settings failure.") + await store.updateSettingsModel("rejected-model") + #expect(store.settings.lastErrorMessage == "Existing settings failure.") + + let token = try await store.settingsService.beginRuntimeCutover() + try store.settingsService.cancelRuntimeCutover(token: token) + + #expect(store.settings.isLoading == false) + #expect(store.settings.lastErrorMessage == "Existing settings failure.") + } + + @Test func canceledCutoverReplaysDeferredRawIntentsOnceAfterNextCommit() async throws { + let initial = settingsSnapshot(model: "initial-model") + let backend = FakeCodexReviewBackend(settings: backendSnapshot(initial)) + let store = makeStore(initial: initial, backend: backend) + let canceledToken = try await store.settingsService.beginRuntimeCutover() + + await store.updateSettingsModel("deferred-model") + await store.refreshSettings() + try store.settingsService.cancelRuntimeCutover(token: canceledToken) + #expect(await backend.recordedCommands().isEmpty) + + let replacementToken = try await store.settingsService.beginRuntimeCutover() + #expect(await backend.recordedCommands().isEmpty) + try await store.settingsService.commitRuntimeSnapshot( + token: replacementToken, + snapshot: initial + ) + + let commands = await backend.recordedCommands() + #expect(commands.filter(\.isSettingsWrite).count == 1) + #expect(commands.filter(\.isSettingsRead).count == 1) + #expect(await backend.settingsSnapshot().model == "deferred-model") + #expect(store.settings.selectedModel == "deferred-model") + } + + @Test func cancellationTokenMisuseIsTypedAndNeverMutatesState() async throws { + let initial = settingsSnapshot(model: "initial-model") + let backend = FakeCodexReviewBackend(settings: backendSnapshot(initial)) + let store = makeStore(initial: initial, backend: backend) + let otherStore = makeStore( + initial: initial, + backend: FakeCodexReviewBackend(settings: backendSnapshot(initial)) + ) + let token = try await store.settingsService.beginRuntimeCutover() + let tokenCopy = token + + #expect(throws: CodexReviewSettingsService.RuntimeCutoverError.foreignToken) { + try otherStore.settingsService.cancelRuntimeCutover(token: token) + } + #expect(otherStore.settingsService.runtimeCutoverStatus == .active) + + try store.settingsService.cancelRuntimeCutover(token: tokenCopy) + #expect(throws: CodexReviewSettingsService.RuntimeCutoverError.tokenAlreadyConsumed) { + try store.settingsService.cancelRuntimeCutover(token: token) + } + #expect(store.settingsService.runtimeCutoverStatus == .awaitingRecovery) + + let nextToken = try await store.settingsService.beginRuntimeCutover() + #expect(throws: CodexReviewSettingsService.RuntimeCutoverError.staleToken) { + try store.settingsService.cancelRuntimeCutover(token: tokenCopy) + } + #expect(store.settingsService.runtimeCutoverStatus == .awaitingCommit) + #expect(await backend.recordedCommands().isEmpty) + try store.settingsService.cancelRuntimeCutover(token: nextToken) + } + @Test func deferredSelectionIsRenormalizedAndSerializedBeforeNewEdit() async throws { let initial = settingsSnapshot( model: "initial-model",