From 9e8be21c16f7ceaf29e6bd4595369aa5e509c16e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:03:00 +0900 Subject: [PATCH 1/4] fix(app-server): await review terminal before cleanup Pin CodexKit at the corrected review-generation routing revision and make interruption retain lifecycle ownership until the outer review turn reaches terminal. Update app-server and host fixtures to model redirected child and outer terminal events, including retained restart identity recovery. --- Package.resolved | 4 +- Package.swift | 2 +- .../AppServerCodexReviewBackend.swift | 18 +++++- .../AppServerClientTests.swift | 64 ++++++++++++++++++- .../CodexReviewHostTests.swift | 62 +++++++++++++++++- .../xcshareddata/swiftpm/Package.resolved | 10 ++- 6 files changed, 150 insertions(+), 10 deletions(-) diff --git a/Package.resolved b/Package.resolved index 5342e86..2f7cee9 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "77addbcb28a7261cbf082f3e63d433de400e80eda0c78ecf87bb7c653bb9da3a", + "originHash" : "db686e8128086fbeb71f9dc90e21b592d1c55f433180924062def874be45aa52", "pins" : [ { "identity" : "codexkit", "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "c18e0636ef11d47c508836bdeaa424ae1be5c72b" + "revision" : "99ef48d1306435c0bb801b1b1c233f31685421c6" } }, { diff --git a/Package.swift b/Package.swift index 1cfcc44..f5fe9d8 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ let packageDirectory = URL(fileURLWithPath: #filePath) let localCodexKitPath = packageDirectory .appendingPathComponent("dependencies/CodexKit", isDirectory: true) .path -let codexKitFallbackRevision = "c18e0636ef11d47c508836bdeaa424ae1be5c72b" +let codexKitFallbackRevision = "99ef48d1306435c0bb801b1b1c233f31685421c6" let codexKitDependency: Package.Dependency = FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift") ? .package(path: localCodexKitPath) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index e532ccc..3532bdc 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -504,7 +504,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { ) async { await Task { [appServer] in do { - _ = try await session.cancel() + _ = try await Self.interruptAndAwaitTerminal(session) } catch { appServerBackendLogger.error( "Failed to cancel a review with invalid identity before cleanup: \(error.localizedDescription, privacy: .public)" @@ -599,7 +599,21 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { message: "Interrupt requires the active SDK review session for its attempt." ) } - return try await activeReview.session.cancel() + return try await Self.interruptAndAwaitTerminal(activeReview.session) + } + + private nonisolated static func interruptAndAwaitTerminal( + _ session: CodexReviewSession + ) async throws -> CodexTurnCancellation { + // Cleanup callers may themselves be cancelled while tearing down a run. + // Once interruption starts, keep ownership until the outer review turn + // reaches terminal so no later cleanup can race its final events. + let interruption = Task { + let cancellation = try await session.cancel() + _ = try await session.collect() + return cancellation + } + return try await interruption.value } private func cleanupAppServerReview( diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 171c486..6557795 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -681,7 +681,22 @@ struct AppServerClientTests { let backend = await makeBackend(appServer: runtime.server) let attempt = try await backend.startReview(makeReviewStart()) - try await backend.interruptReview(attempt.attempt, reason: .init(message: "Stop")) + let interruptTask = Task { + try await backend.interruptReview(attempt.attempt, reason: .init(message: "Stop")) + } + defer { + interruptTask.cancel() + } + await runtime.transport.waitForRequest(.turnInterrupt) + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-1", + state: .interrupted + ) + try await withTimeout { + try await interruptTask.value + } let requests = await runtime.transport.recordedRequests() #expect(requests.map(\.request.operation) == [ @@ -702,6 +717,34 @@ struct AppServerClientTests { #expect(interrupt.1 == "turn-1") } + @Test func interruptReviewCompletesTerminalWaitAfterCallerCancellation() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-1", + reviewThreadID: "thread-1" + ) + try await runtime.transport.handleTurnInterrupt { _ in } + let backend = await makeBackend(appServer: runtime.server) + let attempt = try await backend.startReview(makeReviewStart()) + + let interruptTask = Task { + try await backend.interruptReview(attempt.attempt, reason: .init(message: "Stop")) + } + await runtime.transport.waitForRequest(.turnInterrupt) + interruptTask.cancel() + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-1", + state: .interrupted + ) + + try await withTimeout { + try await interruptTask.value + } + } + @Test func startReviewMapsRequestFailureToTypedOperation() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueFailure( @@ -779,6 +822,12 @@ struct AppServerClientTests { code: -32_002 ) } + + let runID = try ReviewRunID(validating: "run-1") + let retained = await backend.discardAllPreparedReviewRestarts( + ownedAttemptsByRunID: [runID: attempt.attempt] + ) + #expect(retained == [runID: [attempt.attempt]]) } @Test func restartReviewMapsUnavailableTokenToTypedOperation() async throws { @@ -844,10 +893,21 @@ struct AppServerClientTests { await runtime.transport.waitForRequest(.turnInterrupt, count: 2) try await emitTurn( on: runtime, - threadID: "thread-1", + threadID: "thread-review-child", turnID: "turn-new", state: .interrupted ) + try await runtime.notificationEmitter.emitItemCompleted( + threadID: "thread-1", + turnID: "turn-old", + item: .agentMessage(id: "review-output", text: "Review interrupted") + ) + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-old", + state: .interrupted + ) let token = try await withTimeout { try await prepareTask.value } diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index fee0345..4c5cb93 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -5116,7 +5116,18 @@ struct CodexReviewHostTests { store.reviewRuns.first?.core.attempt?.turnID.rawValue == "turn-first" }) - try await store.switchAccount(CodexReviewKit.CodexReviewAccount(email: "second@example.com")) + let switchTask = Task { @MainActor in + try await store.switchAccount( + CodexReviewKit.CodexReviewAccount(email: "second@example.com") + ) + } + await firstTransport.waitForRequest(.turnInterrupt) + try await emitInterruptedTurn( + on: firstTransport, + threadID: "thread-first", + turnID: "turn-first" + ) + try await switchTask.value let result = try await reviewRead await secondTransport.waitForRequestCount(2) await firstTransport.waitForRequestCount(7) @@ -5181,7 +5192,16 @@ struct CodexReviewHostTests { store.reviewRuns.first?.core.attempt?.turnID.rawValue == "turn-active" }) - await store.logout() + let logoutTask = Task { @MainActor in + await store.logout() + } + await firstTransport.waitForRequest(.turnInterrupt) + try await emitInterruptedTurn( + on: firstTransport, + threadID: "thread-active", + turnID: "turn-active" + ) + await logoutTask.value let result = try await reviewRead await secondTransport.waitForRequestCount(2) @@ -5333,6 +5353,11 @@ struct CodexReviewHostTests { } let methodsBeforeInterruptCompletes = await transport.recordedRequests().map(\.request.operation) await interruptGate.open() + try await emitInterruptedTurn( + on: transport, + threadID: "thread-1", + turnID: "turn-1" + ) await stopTask.value let result = try await reviewRead @@ -5385,6 +5410,11 @@ struct CodexReviewHostTests { }) #expect(await stopFinished.isCompleted() == false) await interruptGate.open() + try await emitInterruptedTurn( + on: transport, + threadID: "thread-1", + turnID: "turn-1" + ) await stopTask.value let result = try await reviewRead.value @@ -5436,6 +5466,20 @@ struct CodexReviewHostTests { try #require(await waitUntil(timeout: .seconds(2)) { await transport.recordedRequests().map(\.request.operation).contains(.turnInterrupt) }) + try await emitInterruptedTurn( + on: transport, + threadID: "thread-1", + turnID: "turn-1" + ) + try #require(await waitUntil(timeout: .seconds(2)) { + guard let run = store.reviewRuns.first else { + return false + } + if case .waitingForNetwork = run.presentation.lifecycle { + return true + } + return false + }) let stopFinished = CompletionFlag() let stopTask = Task { @MainActor in @@ -6156,6 +6200,20 @@ struct CodexReviewHostTests { } } +private func emitInterruptedTurn( + on transport: FakeCodexAppServerTransport, + threadID: CodexThreadID, + turnID: CodexTurnID +) async throws { + try await transport.notificationEmitter.emitTurnCompleted( + threadID: threadID, + turn: try CodexAppServerTestTurn( + snapshot: .init(id: turnID, state: .interrupted), + items: [] + ) + ) +} + @MainActor private func exerciseUnknownPrimaryCancellation( previousAccountKey: String?, diff --git a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e599fba..1317590 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,14 @@ { - "originHash" : "11d1673255c652381957935c0aa0fcc8f6cba52200a3e3e3c7296d58e6207f76", + "originHash" : "844e7e3b43d92f89827170165177645f6aa45da42a6028dea22353eb9f9cbdde", "pins" : [ + { + "identity" : "codexkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lynnswap/CodexKit.git", + "state" : { + "revision" : "99ef48d1306435c0bb801b1b1c233f31685421c6" + } + }, { "identity" : "eventsource", "kind" : "remoteSourceControl", From 9daf2df8b54c48fcf92521d6c05ba89b4f05c45f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:25:44 +0900 Subject: [PATCH 2/4] fix(app-server): retain terminal barrier after interrupt errors --- .../AppServerCodexReviewBackend.swift | 25 +++++++++-- .../AppServerClientTests.swift | 41 ++++++++++++++++++- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 3532bdc..3aa706f 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -605,12 +605,29 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { private nonisolated static func interruptAndAwaitTerminal( _ session: CodexReviewSession ) async throws -> CodexTurnCancellation { + try await interruptAndAwaitTerminal( + interrupt: { try await session.cancel() }, + awaitTerminal: { _ = try await session.collect() } + ) + } + + nonisolated static func interruptAndAwaitTerminal( + interrupt: @escaping @Sendable () async throws -> Cancellation, + awaitTerminal: @escaping @Sendable () async throws -> Void + ) async throws -> Cancellation { // Cleanup callers may themselves be cancelled while tearing down a run. - // Once interruption starts, keep ownership until the outer review turn - // reaches terminal so no later cleanup can race its final events. + // The terminal barrier remains authoritative even when interrupt + // acknowledgement fails because cleanup callers may discard that error. let interruption = Task { - let cancellation = try await session.cancel() - _ = try await session.collect() + let cancellation: Cancellation + do { + cancellation = try await interrupt() + } catch { + let interruptError = error + try await awaitTerminal() + throw interruptError + } + try await awaitTerminal() return cancellation } return try await interruption.value diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 6557795..137179d 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -780,11 +780,22 @@ struct AppServerClientTests { for: .turnInterrupt ) - do { + let interruptTask = Task { try await backend.interruptReview( attempt.attempt, reason: .init(message: "Stop") ) + } + await runtime.transport.waitForRequest(.turnInterrupt) + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-1", + state: .interrupted + ) + + do { + try await interruptTask.value Issue.record("Expected interruptReview to preserve its typed operation failure.") } catch { try expectServerRequestFailure( @@ -797,6 +808,30 @@ struct AppServerClientTests { #expect(await runtime.transport.recordedRequests(for: .threadResume).isEmpty) } + @Test func interruptFailureRetainsTerminalBarrier() async throws { + let terminalGate = CodexAppServerTestGate() + let interruption = Task { + try await AppServerCodexReviewBackend.interruptAndAwaitTerminal( + interrupt: { () async throws -> Void in + throw AppServerClientTestInterruptionError.rejected + }, + awaitTerminal: { + await terminalGate.waitIgnoringCancellation() + } + ) + } + + await terminalGate.waitUntilBlocked() + await terminalGate.open() + + do { + try await interruption.value + Issue.record("Expected the interrupt failure after the terminal barrier opened.") + } catch { + #expect(error as? AppServerClientTestInterruptionError == .rejected) + } + } + @Test func prepareRestartMapsRequestFailureToTypedOperation() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") @@ -1136,6 +1171,10 @@ private enum AppServerClientTestTimeout: Error { case timedOut } +private enum AppServerClientTestInterruptionError: Error, Equatable { + case rejected +} + private extension ReviewBackendFailure { var operationFailure: ReviewBackendOperationFailure? { guard case .operation(let failure) = self else { From 95b589d3d423ba95d8dfbb675f0a06c8fa3a8b19 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:47:35 +0900 Subject: [PATCH 3/4] fix(app-server): avoid waits after rejected interrupts --- .../AppServerCodexReviewBackend.swift | 41 +++++++++-- .../AppServerClientTests.swift | 68 +++++++++++++++---- 2 files changed, 91 insertions(+), 18 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 3aa706f..26d2f3e 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -607,24 +607,31 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { ) async throws -> CodexTurnCancellation { try await interruptAndAwaitTerminal( interrupt: { try await session.cancel() }, - awaitTerminal: { _ = try await session.collect() } + awaitTerminal: { _ = try await session.collect() }, + terminalMayStillArriveAfterInterruptFailure: { error in + Self.terminalMayStillArrive(afterInterruptFailure: error) + } ) } nonisolated static func interruptAndAwaitTerminal( interrupt: @escaping @Sendable () async throws -> Cancellation, - awaitTerminal: @escaping @Sendable () async throws -> Void + awaitTerminal: @escaping @Sendable () async throws -> Void, + terminalMayStillArriveAfterInterruptFailure: + @escaping @Sendable (any Error) -> Bool ) async throws -> Cancellation { // Cleanup callers may themselves be cancelled while tearing down a run. - // The terminal barrier remains authoritative even when interrupt - // acknowledgement fails because cleanup callers may discard that error. + // Keep terminal ownership only when a live connection can still deliver + // the accepted interrupt's terminal after its acknowledgement failed. let interruption = Task { let cancellation: Cancellation do { cancellation = try await interrupt() } catch { let interruptError = error - try await awaitTerminal() + if terminalMayStillArriveAfterInterruptFailure(interruptError) { + try await awaitTerminal() + } throw interruptError } try await awaitTerminal() @@ -633,6 +640,30 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { return try await interruption.value } + private nonisolated static func terminalMayStillArrive( + afterInterruptFailure error: any Error + ) -> Bool { + guard case .request(let failure) = error as? CodexAppServerError else { + return false + } + return terminalMayStillArrive(afterInterruptRequestFailure: failure.kind) + } + + nonisolated static func terminalMayStillArrive( + afterInterruptRequestFailure failure: CodexRequestFailure.Kind + ) -> Bool { + switch failure { + case .invalidResponse: + return true + case .encode, .server, .overloadRetryExhausted: + return false + case .write, .transport, .deadlineExceeded: + // CodexKit terminates the connection before surfacing a post-write + // failure in these paths; their pre-write forms were never accepted. + return false + } + } + private func cleanupAppServerReview( _ attempt: ReviewAttempt ) async { diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 137179d..379569f 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -780,22 +780,11 @@ struct AppServerClientTests { for: .turnInterrupt ) - let interruptTask = Task { + do { try await backend.interruptReview( attempt.attempt, reason: .init(message: "Stop") ) - } - await runtime.transport.waitForRequest(.turnInterrupt) - try await emitTurn( - on: runtime, - threadID: "thread-1", - turnID: "turn-1", - state: .interrupted - ) - - do { - try await interruptTask.value Issue.record("Expected interruptReview to preserve its typed operation failure.") } catch { try expectServerRequestFailure( @@ -808,7 +797,57 @@ struct AppServerClientTests { #expect(await runtime.transport.recordedRequests(for: .threadResume).isEmpty) } - @Test func interruptFailureRetainsTerminalBarrier() async throws { + @Test func interruptFailureClassificationWaitsOnlyForLiveInvalidResponse() { + #expect(AppServerCodexReviewBackend.terminalMayStillArrive( + afterInterruptRequestFailure: .invalidResponse( + expectedType: "EmptyResponse", + message: "Malformed response", + rawData: nil + ) + )) + #expect(!AppServerCodexReviewBackend.terminalMayStillArrive( + afterInterruptRequestFailure: .encode(message: "Encoding failed") + )) + #expect(!AppServerCodexReviewBackend.terminalMayStillArrive( + afterInterruptRequestFailure: .write(.closed) + )) + #expect(!AppServerCodexReviewBackend.terminalMayStillArrive( + afterInterruptRequestFailure: .transport(.closed) + )) + #expect(!AppServerCodexReviewBackend.terminalMayStillArrive( + afterInterruptRequestFailure: .server(.init(code: -32_011, message: "Rejected")) + )) + #expect(!AppServerCodexReviewBackend.terminalMayStillArrive( + afterInterruptRequestFailure: .deadlineExceeded(.seconds(1)) + )) + #expect(!AppServerCodexReviewBackend.terminalMayStillArrive( + afterInterruptRequestFailure: .overloadRetryExhausted( + last: .init(code: -32_001, message: "Overloaded"), + attempts: 3 + ) + )) + } + + @Test func definitiveInterruptFailureSkipsTerminalBarrier() async { + do { + try await AppServerCodexReviewBackend.interruptAndAwaitTerminal( + interrupt: { () async throws -> Void in + throw AppServerClientTestInterruptionError.rejected + }, + awaitTerminal: { + Issue.record("A definitive interrupt failure must not enter the terminal barrier.") + }, + terminalMayStillArriveAfterInterruptFailure: { _ in + false + } + ) + Issue.record("Expected the definitive interrupt failure.") + } catch { + #expect(error as? AppServerClientTestInterruptionError == .rejected) + } + } + + @Test func ambiguousInterruptFailureRetainsTerminalBarrier() async throws { let terminalGate = CodexAppServerTestGate() let interruption = Task { try await AppServerCodexReviewBackend.interruptAndAwaitTerminal( @@ -817,6 +856,9 @@ struct AppServerClientTests { }, awaitTerminal: { await terminalGate.waitIgnoringCancellation() + }, + terminalMayStillArriveAfterInterruptFailure: { _ in + true } ) } From 1d3336dc3053f46ccbb9edcb2bd9dad6b5a009ce Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:57:43 +0900 Subject: [PATCH 4/4] fix(app-server): preserve interrupt request failures --- .../CodexReviewAppServer/AppServerCodexReviewBackend.swift | 4 +++- Tests/CodexReviewAppServerTests/AppServerClientTests.swift | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 26d2f3e..378ef68 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -630,7 +630,9 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { } catch { let interruptError = error if terminalMayStillArriveAfterInterruptFailure(interruptError) { - try await awaitTerminal() + // The barrier owns lifecycle completion, not error selection: + // the interrupt request failure remains the operation result. + _ = try? await awaitTerminal() } throw interruptError } diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 379569f..2f1aff0 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -847,7 +847,7 @@ struct AppServerClientTests { } } - @Test func ambiguousInterruptFailureRetainsTerminalBarrier() async throws { + @Test func ambiguousInterruptFailureRetainsTerminalBarrierAndOriginalError() async throws { let terminalGate = CodexAppServerTestGate() let interruption = Task { try await AppServerCodexReviewBackend.interruptAndAwaitTerminal( @@ -856,6 +856,7 @@ struct AppServerClientTests { }, awaitTerminal: { await terminalGate.waitIgnoringCancellation() + throw AppServerClientTestInterruptionError.terminalFailed }, terminalMayStillArriveAfterInterruptFailure: { _ in true @@ -1215,6 +1216,7 @@ private enum AppServerClientTestTimeout: Error { private enum AppServerClientTestInterruptionError: Error, Equatable { case rejected + case terminalFailed } private extension ReviewBackendFailure {