Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 49 additions & 8 deletions Sources/CodexReview/Settings/CodexReviewSettingsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down