From 7960511d323186912816490351f1e1fa2e2b313a Mon Sep 17 00:00:00 2001
From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com>
Date: Mon, 10 Aug 2026 02:17:11 +0900
Subject: [PATCH 1/5] ci(docs): publish DocC on main pushes
Generate warning-free static documentation for every public library product and deploy it through GitHub Pages. Keep pull requests as build-only validation and make the external consumer fixture independent of checkout directory names.
---
.github/workflows/documentation.yml | 74 +++++++++++++++++++
Docs/DocCSite/index.html | 40 ++++++++++
.../Package.swift | 2 +-
Package.resolved | 20 ++++-
Package.swift | 1 +
README.md | 2 +
.../CodexThreadOperations.swift | 2 +
.../CodexAppServerTestRuntime.swift | 8 +-
scripts/generate-docc-site.sh | 57 ++++++++++++++
9 files changed, 200 insertions(+), 6 deletions(-)
create mode 100644 .github/workflows/documentation.yml
create mode 100644 Docs/DocCSite/index.html
create mode 100755 scripts/generate-docc-site.sh
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
new file mode 100644
index 0000000..aa1b6f3
--- /dev/null
+++ b/.github/workflows/documentation.yml
@@ -0,0 +1,74 @@
+name: Documentation
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ paths:
+ - .github/workflows/documentation.yml
+ - Docs/DocCSite/**
+ - Package.swift
+ - Package.resolved
+ - README.md
+ - Sources/**
+ - scripts/generate-docc-site.sh
+ workflow_dispatch:
+
+concurrency:
+ group: documentation-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+env:
+ DEVELOPER_DIR: /Applications/Xcode_26.6.0.app/Contents/Developer
+ EXPECTED_XCODE_VERSION_LINE: Xcode 26.6
+ EXPECTED_XCODE_BUILD_LINE: Build version 17F113
+
+jobs:
+ build:
+ name: Build DocC
+ runs-on: macos-26
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ pages: read
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Verify GitHub Actions pinning
+ run: ruby scripts/verify-github-actions-pinning.rb
+
+ - name: Verify pinned Xcode
+ run: scripts/verify-ci-xcode.sh
+
+ - name: Configure GitHub Pages
+ if: github.event_name == 'push'
+ uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
+
+ - name: Build documentation
+ run: scripts/generate-docc-site.sh "$RUNNER_TEMP/codexreviewkit-docc" CodexReviewKit
+
+ - name: Upload GitHub Pages artifact
+ if: github.event_name == 'push'
+ uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
+ with:
+ path: ${{ runner.temp }}/codexreviewkit-docc
+
+ deploy:
+ name: Deploy GitHub Pages
+ if: github.event_name == 'push'
+ needs: build
+ runs-on: ubuntu-24.04
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
diff --git a/Docs/DocCSite/index.html b/Docs/DocCSite/index.html
new file mode 100644
index 0000000..8ee04c2
--- /dev/null
+++ b/Docs/DocCSite/index.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+ CodexReviewKit Documentation
+
+
+
+
+ CodexReviewKit Documentation
+ API documentation for the public Swift package products.
+
+ Source and installation
+
+
+
diff --git a/Fixtures/CodexReviewKitProductConsumer/Package.swift b/Fixtures/CodexReviewKitProductConsumer/Package.swift
index c4b69af..2616f8f 100644
--- a/Fixtures/CodexReviewKitProductConsumer/Package.swift
+++ b/Fixtures/CodexReviewKitProductConsumer/Package.swift
@@ -14,7 +14,7 @@ let package = Package(
),
],
dependencies: [
- .package(path: "../.."),
+ .package(name: "CodexReviewKit", path: "../.."),
],
targets: [
.executableTarget(
diff --git a/Package.resolved b/Package.resolved
index 3dc5273..dc2689f 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "e64b4c51c05f762b778f36a7fcff26834a31424abb07eee8f8173ea2cbcf725b",
+ "originHash" : "9317724d9c293b71e4fa617d888b222f6c73c88c90968f2541559ccdc34e3f36",
"pins" : [
{
"identity" : "eventsource",
@@ -46,6 +46,24 @@
"version" : "1.4.1"
}
},
+ {
+ "identity" : "swift-docc-plugin",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-docc-plugin",
+ "state" : {
+ "revision" : "647c708be89f834fa6a6d4945442793a77ddf5b6",
+ "version" : "1.5.0"
+ }
+ },
+ {
+ "identity" : "swift-docc-symbolkit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-docc-symbolkit",
+ "state" : {
+ "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
+ "version" : "1.0.0"
+ }
+ },
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
diff --git a/Package.swift b/Package.swift
index 91535b9..46cb33e 100644
--- a/Package.swift
+++ b/Package.swift
@@ -49,6 +49,7 @@ let package = Package(
.package(url: "https://github.com/apple/swift-nio.git", from: "2.97.1"),
.package(url: "https://github.com/lynnswap/ObservationBridge.git", .upToNextMinor(from: "0.12.0")),
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.0"),
+ .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.5.0"),
],
targets: [
.target(
diff --git a/README.md b/README.md
index d039570..6307e58 100644
--- a/README.md
+++ b/README.md
@@ -142,6 +142,8 @@ used by that Claude Code session.
## More Detail
+- [API documentation](https://lynnswap.github.io/CodexReviewKit/): generated
+ DocC for every public library product.
- [Architecture](Docs/architecture.md): package boundaries, runtime flow, and
test responsibilities.
- [CodexKit integration design](Docs/codexkit-integration.md): the canonical
diff --git a/Sources/CodexAppServerKit/CodexThreadOperations.swift b/Sources/CodexAppServerKit/CodexThreadOperations.swift
index 5dd3127..16d027b 100644
--- a/Sources/CodexAppServerKit/CodexThreadOperations.swift
+++ b/Sources/CodexAppServerKit/CodexThreadOperations.swift
@@ -48,6 +48,8 @@ extension CodexThread {
/// - Parameters:
/// - prompt: The structured prompt to send.
/// - options: Per-turn generation options.
+ /// - timeout: The maximum duration to wait for a terminal outcome, or
+ /// `nil` to wait without a deadline.
/// - Returns: The completed response collected from app-server events.
public func respond(
to prompt: CodexPrompt,
diff --git a/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift b/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift
index 8874db4..f2637ef 100644
--- a/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift
+++ b/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift
@@ -637,7 +637,7 @@ public final class CodexAppServerTestDeadlineClock: Sendable {
///
/// This type does not launch `codex` or any external process. Tests enqueue
/// responses and emit notifications through ``transport`` while exercising the
-/// same public ``CodexAppServer`` API that production code uses.
+/// same public `CodexAppServer` API that production code uses.
public struct CodexAppServerTestRuntime: Sendable {
/// The app-server domain container under test.
public let server: CodexAppServer
@@ -678,7 +678,7 @@ public struct CodexAppServerTestRuntime: Sendable {
/// Creates a test runtime without launching a real app-server process.
///
/// The runtime automatically enqueues the `initialize` response required by
- /// ``CodexAppServer`` startup.
+ /// `CodexAppServer` startup.
///
/// - Parameters:
/// - transport: The in-memory transport to use.
@@ -721,7 +721,7 @@ public struct CodexAppServerTestRuntime: Sendable {
/// Creates a test runtime whose app-server thread APIs are backed by a mutable store.
///
- /// The returned runtime still exercises the public ``CodexAppServer`` API,
+ /// The returned runtime still exercises the public `CodexAppServer` API,
/// while callers can mutate `threadStore` after startup:
///
/// ```swift
@@ -751,7 +751,7 @@ public struct CodexAppServerTestRuntime: Sendable {
/// Creates a test runtime whose thread APIs are backed by validated opaque fixtures.
///
- /// The returned runtime still exercises the public ``CodexAppServer`` API.
+ /// The returned runtime still exercises the public `CodexAppServer` API.
/// Higher-level code can build its normal data container from ``server``:
///
/// ```swift
diff --git a/scripts/generate-docc-site.sh b/scripts/generate-docc-site.sh
new file mode 100755
index 0000000..7218918
--- /dev/null
+++ b/scripts/generate-docc-site.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+if [[ "$#" -ne 2 ]]; then
+ printf 'usage: %s \n' "$0" >&2
+ exit 64
+fi
+
+output_directory="$1"
+hosting_base_path="${2%/}"
+
+if [[ "$output_directory" != /* ]]; then
+ printf 'output directory must be absolute: %s\n' "$output_directory" >&2
+ exit 64
+fi
+
+if [[ -z "$hosting_base_path" ]]; then
+ printf 'hosting base path must not be empty\n' >&2
+ exit 64
+fi
+
+if [[ -e "$output_directory" ]]; then
+ printf 'output directory already exists: %s\n' "$output_directory" >&2
+ exit 1
+fi
+
+script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+repository_root="$(cd "$script_directory/.." && pwd)"
+cd "$repository_root"
+
+targets=(
+ CodexAppServerKit
+ CodexAppServerKitTesting
+ CodexDataKit
+ CodexReviewKit
+ CodexReviewHost
+ ReviewUI
+ ReviewUIPreviewSupport
+ TextTransitions
+)
+
+mkdir -p "$output_directory"
+cp Docs/DocCSite/index.html "$output_directory/index.html"
+
+for target in "${targets[@]}"; do
+ target_output="$output_directory/$target"
+ swift package \
+ --allow-writing-to-directory "$target_output" \
+ generate-documentation \
+ --target "$target" \
+ --output-path "$target_output" \
+ --disable-indexing \
+ --transform-for-static-hosting \
+ --hosting-base-path "$hosting_base_path/$target" \
+ --warnings-as-errors
+done
From 518cdce9b48fda1c4d71e6602053b29fbaf50d5a Mon Sep 17 00:00:00 2001
From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com>
Date: Mon, 10 Aug 2026 07:14:36 +0900
Subject: [PATCH 2/5] test(app-server): synchronize reasoning assertions
---
.../CodexAppServerKitTests.swift | 58 ++++++++++++++++++-
1 file changed, 56 insertions(+), 2 deletions(-)
diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift
index e3481b1..5d55aa4 100644
--- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift
+++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift
@@ -6778,9 +6778,59 @@ struct CodexAppServerKitTests {
router: router,
connectionLease: harness.lease
)
- let eventsTask = Task { try await collect(thread.events) }
- let logsTask = Task { try await collect(thread.logEntries) }
+ let reasoningEventsObserved = CodexAppServerTestGate()
+ let reasoningLogsObserved = CodexAppServerTestGate()
+ let eventsTask = Task {
+ var events: [CodexThreadEvent] = []
+ var observedSummaryPart = false
+ var observedContentDelta = false
+ for try await event in thread.events {
+ events.append(event)
+ switch event {
+ case .reasoningSummaryPartAdded(let part, let turnID):
+ observedSummaryPart = observedSummaryPart
+ || (part.id == "reasoning-1:summary:0" && turnID == "turn-1")
+ case .reasoningDelta(let delta, let turnID):
+ observedContentDelta = observedContentDelta
+ || (delta.id == "reasoning-1:content:1"
+ && delta.delta == "Raw trace"
+ && turnID == "turn-1")
+ default:
+ break
+ }
+ if observedSummaryPart && observedContentDelta {
+ await reasoningEventsObserved.open()
+ }
+ }
+ return events
+ }
+ let logsTask = Task {
+ var logs: [CodexThreadLogEntry] = []
+ var observedSummaryStart = false
+ var observedSummaryDelta = false
+ var observedContentDelta = false
+ for try await log in thread.logEntries {
+ logs.append(log)
+ observedSummaryStart = observedSummaryStart
+ || (log.id == "reasoning-1:summary:0" && log.phase == .started)
+ observedSummaryDelta = observedSummaryDelta
+ || (log.reasoningDelta?.id == "reasoning-1:summary:0"
+ && log.reasoningDelta?.delta == "Checking")
+ observedContentDelta = observedContentDelta
+ || (log.reasoningDelta?.id == "reasoning-1:content:1"
+ && log.reasoningDelta?.delta == "Raw trace")
+ if observedSummaryStart && observedSummaryDelta && observedContentDelta {
+ await reasoningLogsObserved.open()
+ }
+ }
+ return logs
+ }
let transcriptsTask = Task { try await collect(thread.transcriptUpdates) }
+ defer {
+ eventsTask.cancel()
+ logsTask.cancel()
+ transcriptsTask.cancel()
+ }
#expect(await eventually {
router.threadSubscriberCountForTesting(for: "thread-1") == 3
})
@@ -6825,6 +6875,10 @@ struct CodexAppServerKitTests {
delta: "Raw trace"
)
)
+ try await withTimeout {
+ try await reasoningEventsObserved.wait()
+ try await reasoningLogsObserved.wait()
+ }
try await transport.emitServerNotification(
method: "item/completed",
params: ThreadItemParams(
From 95cf9ab989da982186fc0be7b673153044a97e35 Mon Sep 17 00:00:00 2001
From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com>
Date: Mon, 10 Aug 2026 07:23:31 +0900
Subject: [PATCH 3/5] ci(docs): publish DocC only from main
---
.github/workflows/documentation.yml | 15 +--------------
1 file changed, 1 insertion(+), 14 deletions(-)
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
index aa1b6f3..02f023f 100644
--- a/.github/workflows/documentation.yml
+++ b/.github/workflows/documentation.yml
@@ -4,20 +4,10 @@ on:
push:
branches:
- main
- pull_request:
- paths:
- - .github/workflows/documentation.yml
- - Docs/DocCSite/**
- - Package.swift
- - Package.resolved
- - README.md
- - Sources/**
- - scripts/generate-docc-site.sh
- workflow_dispatch:
concurrency:
group: documentation-${{ github.ref }}
- cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+ cancel-in-progress: false
env:
DEVELOPER_DIR: /Applications/Xcode_26.6.0.app/Contents/Developer
@@ -45,21 +35,18 @@ jobs:
run: scripts/verify-ci-xcode.sh
- name: Configure GitHub Pages
- if: github.event_name == 'push'
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Build documentation
run: scripts/generate-docc-site.sh "$RUNNER_TEMP/codexreviewkit-docc" CodexReviewKit
- name: Upload GitHub Pages artifact
- if: github.event_name == 'push'
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: ${{ runner.temp }}/codexreviewkit-docc
deploy:
name: Deploy GitHub Pages
- if: github.event_name == 'push'
needs: build
runs-on: ubuntu-24.04
permissions:
From 66f59a05089afa0b4a3266d24dbe87811018aed4 Mon Sep 17 00:00:00 2001
From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com>
Date: Mon, 10 Aug 2026 08:18:22 +0900
Subject: [PATCH 4/5] fix(host): publish auth completion after ownership
release
---
.../AccountRuntimeTransitionCoordinator.swift | 27 +-
.../LiveCodexReviewStoreBackend.swift | 295 ++++++++++++------
.../CodexReviewHostTests.swift | 94 +++++-
3 files changed, 301 insertions(+), 115 deletions(-)
diff --git a/Sources/CodexReviewHost/AccountRuntimeTransitionCoordinator.swift b/Sources/CodexReviewHost/AccountRuntimeTransitionCoordinator.swift
index 8fac15a..6be7391 100644
--- a/Sources/CodexReviewHost/AccountRuntimeTransitionCoordinator.swift
+++ b/Sources/CodexReviewHost/AccountRuntimeTransitionCoordinator.swift
@@ -127,6 +127,8 @@ final class AccountRuntimeTransitionCoordinator {
fileprivate let id: UUID
}
+ typealias PrimaryReconciliationFinishAction = @MainActor @Sendable () -> Void
+
private enum ActiveTransition {
enum RuntimeAuthPhase {
case reading
@@ -171,7 +173,7 @@ final class AccountRuntimeTransitionCoordinator {
let completion: PrimaryReconciliationCompletion
let operation: @MainActor @Sendable (
PrimaryReconciliationReservation
- ) async -> Void
+ ) async -> PrimaryReconciliationFinishAction?
}
private var pendingPrimaryReconciliation: PendingPrimaryReconciliation?
@@ -300,7 +302,7 @@ final class AccountRuntimeTransitionCoordinator {
_ admission: LoginAdmission,
operation: @escaping @MainActor @Sendable (
PrimaryReconciliationReservation
- ) async -> Void
+ ) async -> PrimaryReconciliationFinishAction?
) -> PrimaryLoginReconciliationHandoff {
guard case .primaryLogin(let id) = activeTransition,
id == admission.id else {
@@ -591,7 +593,7 @@ final class AccountRuntimeTransitionCoordinator {
func admitPrimaryReconciliation(
_ operation: @escaping @MainActor @Sendable (
PrimaryReconciliationReservation
- ) async -> Void
+ ) async -> PrimaryReconciliationFinishAction?
) -> PrimaryReconciliationAdmission {
if case .account = activeTransition {
guard pendingPrimaryReconciliation == nil else {
@@ -613,7 +615,7 @@ final class AccountRuntimeTransitionCoordinator {
func performStoppedPrimaryReconciliation(
_ operation: @escaping @MainActor @Sendable (
PrimaryReconciliationReservation
- ) async -> Void
+ ) async -> PrimaryReconciliationFinishAction?
) async {
let pending = makePendingPrimaryReconciliation(operation)
if case .account = activeTransition {
@@ -802,7 +804,10 @@ final class AccountRuntimeTransitionCoordinator {
}
}
- private func finishTransition(id: UUID) {
+ private func finishTransition(
+ id: UUID,
+ primaryReconciliationFinishAction: PrimaryReconciliationFinishAction? = nil
+ ) {
guard activeTransition?.id == id else {
preconditionFailure("Only the active account runtime transition can finish.")
}
@@ -820,6 +825,7 @@ final class AccountRuntimeTransitionCoordinator {
nil
}
activeTransition = nil
+ primaryReconciliationFinishAction?()
primaryReconciliationCompletion?.resolve()
let waiters = transitionCompletionWaiters
transitionCompletionWaiters.removeAll(keepingCapacity: false)
@@ -832,7 +838,7 @@ final class AccountRuntimeTransitionCoordinator {
private func startPrimaryReconciliation(
_ operation: @escaping @MainActor @Sendable (
PrimaryReconciliationReservation
- ) async -> Void
+ ) async -> PrimaryReconciliationFinishAction?
) {
startPrimaryReconciliation(makePendingPrimaryReconciliation(operation))
}
@@ -840,7 +846,7 @@ final class AccountRuntimeTransitionCoordinator {
private func makePendingPrimaryReconciliation(
_ operation: @escaping @MainActor @Sendable (
PrimaryReconciliationReservation
- ) async -> Void
+ ) async -> PrimaryReconciliationFinishAction?
) -> PendingPrimaryReconciliation {
let id = UUID()
return .init(
@@ -860,8 +866,11 @@ final class AccountRuntimeTransitionCoordinator {
completion: pending.completion
)
Task { @MainActor [weak self] in
- await pending.operation(pending.reservation)
- self?.finishTransition(id: id)
+ let finishAction = await pending.operation(pending.reservation)
+ self?.finishTransition(
+ id: id,
+ primaryReconciliationFinishAction: finishAction
+ )
}
}
}
diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift
index dd83b93..5bff012 100644
--- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift
+++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift
@@ -41,6 +41,7 @@ package typealias CodexReviewMCPHTTPServerBindChecker = @MainActor @Sendable (
) async throws -> Void
package typealias CodexReviewAuthenticationMutationDidBegin = @Sendable () async -> Void
+package typealias CodexReviewAuthenticationOwnershipWillRelease = @MainActor @Sendable () async -> Void
package typealias CodexReviewAuthenticationCancellationDidRequest = @Sendable () async -> Void
package typealias CodexReviewAuthenticationProductCommitDidApply = @Sendable () async -> Void
package typealias CodexReviewAuthenticationOperationDidBind = @Sendable () async -> Void
@@ -93,6 +94,7 @@ public extension CodexReviewStore {
appServer: CodexAppServer,
appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil,
authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? = nil,
+ authenticationOwnershipWillRelease: CodexReviewAuthenticationOwnershipWillRelease? = nil,
authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? = nil,
authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? = nil,
authenticationOperationDidBind: CodexReviewAuthenticationOperationDidBind? = nil,
@@ -113,6 +115,7 @@ public extension CodexReviewStore {
networkRecoveryPolicy: networkRecoveryPolicy,
appServerLifecycleHandler: appServerLifecycleHandler,
authenticationMutationDidBegin: authenticationMutationDidBegin,
+ authenticationOwnershipWillRelease: authenticationOwnershipWillRelease,
authenticationCancellationDidRequest: authenticationCancellationDidRequest,
authenticationProductCommitDidApply: authenticationProductCommitDidApply,
authenticationOperationDidBind: authenticationOperationDidBind,
@@ -141,6 +144,7 @@ public extension CodexReviewStore {
networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default,
appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil,
authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? = nil,
+ authenticationOwnershipWillRelease: CodexReviewAuthenticationOwnershipWillRelease? = nil,
authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? = nil,
authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? = nil,
authenticationOperationDidBind: CodexReviewAuthenticationOperationDidBind? = nil,
@@ -162,6 +166,7 @@ public extension CodexReviewStore {
mcpHTTPServerBindChecker: mcpHTTPServerBindChecker,
appServerLifecycleHandler: appServerLifecycleHandler,
authenticationMutationDidBegin: authenticationMutationDidBegin,
+ authenticationOwnershipWillRelease: authenticationOwnershipWillRelease,
authenticationCancellationDidRequest: authenticationCancellationDidRequest,
authenticationProductCommitDidApply: authenticationProductCommitDidApply,
authenticationOperationDidBind: authenticationOperationDidBind,
@@ -220,6 +225,21 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
}
return false
}
+
+ var publishesAuthenticationTerminalPhase: Bool {
+ guard case .published(let owner) = self else {
+ return false
+ }
+ if case .primary = owner {
+ return false
+ }
+ return true
+ }
+ }
+
+ private struct LoginSessionResolution {
+ let terminal: LoginSessionTerminal
+ let terminalPhase: CodexReviewAuthModel.Phase?
}
private enum RuntimeAuthReconciliationCause {
@@ -308,6 +328,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
private let mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker
private let appServerRuntimeFactory: AppServerRuntimeFactory
private let appServerCloser: CodexReviewAppServerCloser
+ private let authenticationOwnershipWillRelease: CodexReviewAuthenticationOwnershipWillRelease?
private let authenticationOperationDidBind: CodexReviewAuthenticationOperationDidBind?
private let finalRuntimeRetirementDidClaim: CodexReviewFinalRuntimeRetirementDidClaim?
private let reconciliationDebtDidClear: CodexReviewReconciliationDebtDidClear?
@@ -359,6 +380,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil,
appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil,
authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? = nil,
+ authenticationOwnershipWillRelease: CodexReviewAuthenticationOwnershipWillRelease? = nil,
authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? = nil,
authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? = nil,
authenticationOperationDidBind: CodexReviewAuthenticationOperationDidBind? = nil,
@@ -397,6 +419,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
self.mcpHTTPServerBindChecker = mcpHTTPServerBindChecker ?? Self.defaultMCPHTTPServerBindChecker
self.appServerLifecycleHandler = appServerLifecycleHandler
self.appServerCloser = appServerCloser
+ self.authenticationOwnershipWillRelease = authenticationOwnershipWillRelease
self.authenticationOperationDidBind = authenticationOperationDidBind
self.finalRuntimeRetirementDidClaim = finalRuntimeRetirementDidClaim
self.reconciliationDebtDidClear = reconciliationDebtDidClear
@@ -705,7 +728,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
logger.info("Starting review runtime; forceRestartIfNeeded=\(forceRestartIfNeeded, privacy: .public)")
if let registryLoadFailure {
if shouldPublishRuntimeState(mode: mode) {
- store.auth.updatePhase(.failed(registryLoadFailure))
+ if mode.publishesAuthenticationTerminalPhase {
+ store.auth.updatePhase(.failed(registryLoadFailure))
+ }
store.transitionToFailed(registryLoadFailure.localizedDescription)
}
return false
@@ -873,7 +898,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
if shouldPublishRuntimeState(mode: mode),
let reconciledAccountSnapshot = authResolution.persisted {
applyAccountRegistrySnapshot(reconciledAccountSnapshot, to: store.auth)
- store.auth.updatePhase(.signedOut)
+ if mode.publishesAuthenticationTerminalPhase {
+ store.auth.updatePhase(.signedOut)
+ }
}
store.transitionToRunning(serverURL: serverURL)
await session.mcpHTTPServer?.activate()
@@ -1225,9 +1252,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
await accountRuntimeTransitionCoordinator.performStoppedPrimaryReconciliation {
[weak self, weak store] reservation in
guard let self, let store else {
- return
+ return nil
}
- await self.performPrimaryAuthenticationReconciliation(
+ return await self.performPrimaryAuthenticationReconciliation(
handoff,
reservation: reservation,
auth: store.auth,
@@ -2283,51 +2310,58 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
observation: LoginRootObservation,
auth: CodexReviewAuthModel
) async -> LoginSessionTerminal {
- let terminal: LoginSessionTerminal
+ let resolution: LoginSessionResolution
switch observation {
case .chatGPTOutcome(let outcome):
- terminal = await finishLoginOutcome(
+ resolution = await finishLoginOutcome(
outcome,
session: session,
reason: reason,
auth: auth
)
case .apiKeySucceeded:
- terminal = await finishSuccessfulLogin(session: session, auth: auth)
+ resolution = await finishSuccessfulLogin(session: session, auth: auth)
case .apiKeyOutcomeUnknown:
guard session.provider == .apiKey else {
preconditionFailure("Only an API-key login can report an unknown immediate authentication outcome.")
}
if case .signIn = session.purpose {
- terminal = .primaryRuntimeReconciliation(
- session.takePrimaryAuthenticationReconciliationHandoff(
- cause: .apiKeyOutcomeUnknown(
- previousActiveAccountKey: session.previousActiveAccountKey
+ resolution = .init(
+ terminal: .primaryRuntimeReconciliation(
+ session.takePrimaryAuthenticationReconciliationHandoff(
+ cause: .apiKeyOutcomeUnknown(
+ previousActiveAccountKey: session.previousActiveAccountKey
+ )
)
- )
+ ),
+ terminalPhase: nil
)
} else {
let failure = CodexReviewAuthenticationFailure.login(
message: "The isolated API-key authentication outcome could not be confirmed."
)
- auth.updatePhase(.failed(failure))
- terminal = .failed(failure)
+ resolution = .init(
+ terminal: .failed(failure),
+ terminalPhase: .failed(failure)
+ )
}
case .cancelled:
- terminal = finishCancelledLoginOutcome(reason: reason, auth: auth)
+ resolution = finishCancelledLoginOutcome(reason: reason)
case .failure(let failure):
- auth.updatePhase(.failed(failure))
- terminal = .failed(failure)
+ resolution = .init(
+ terminal: .failed(failure),
+ terminalPhase: .failed(failure)
+ )
case .waiterCancelled(let message):
- terminal = finishCancelledLoginWaiter(
+ resolution = finishCancelledLoginWaiter(
session: session,
reason: reason,
- message: message,
- auth: auth
+ message: message
)
}
await closeLoginRuntimeIfNeeded(session)
+ let terminal = resolution.terminal
if case .primaryRuntimeReconciliation(let handoff) = terminal {
let requiresRuntimeStopHandoff: Bool = switch reason {
case .runtimeFailure, .storeStop:
@@ -2348,9 +2382,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
) {
[weak self, weak auth] reservation in
guard let self, let auth else {
- return
+ return nil
}
- await self.performPrimaryAuthenticationReconciliation(
+ return await self.performPrimaryAuthenticationReconciliation(
handoff,
reservation: reservation,
auth: auth,
@@ -2370,9 +2404,11 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
}
}
} else {
+ await authenticationOwnershipWillRelease?()
await releaseLoginMutationIfNeeded(session)
clearLoginSessionIfCurrent(session)
finishPrimaryLoginAdmissionIfCurrent(session)
+ publishAuthenticationTerminalPhase(resolution.terminalPhase, to: auth)
await reconcilePendingRuntimeAuthInvalidation(auth: auth)
if case .succeeded = terminal,
case .signIn = session.purpose {
@@ -2466,7 +2502,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
session: LoginSession,
reason terminationReason: LoginTerminationReason,
auth: CodexReviewAuthModel
- ) async -> LoginSessionTerminal {
+ ) async -> LoginSessionResolution {
precondition(session.provider == .chatGPT)
switch outcome {
case .succeeded:
@@ -2475,40 +2511,44 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
let failure = CodexReviewAuthenticationFailure.login(
message: message ?? "Authentication failed."
)
- auth.updatePhase(.failed(failure))
- return .failed(failure)
- case .cancelled:
- return finishCancelledLoginOutcome(
- reason: terminationReason,
- auth: auth
+ return .init(
+ terminal: .failed(failure),
+ terminalPhase: .failed(failure)
)
+ case .cancelled:
+ return finishCancelledLoginOutcome(reason: terminationReason)
case .authenticationCommittedNeedsConnectionReconciliation(let reconciliationReason):
if case .cancelOutcomeUnknown = reconciliationReason {
guard case .signIn = session.purpose else {
- return finishCancelledLoginOutcome(
- reason: terminationReason,
- auth: auth
- )
+ return finishCancelledLoginOutcome(reason: terminationReason)
}
- return .primaryRuntimeReconciliation(
- session.takePrimaryAuthenticationReconciliationHandoff(
- cause: .chatGPTCancelOutcomeUnknown(
- previousActiveAccountKey: session.previousActiveAccountKey
+ return .init(
+ terminal: .primaryRuntimeReconciliation(
+ session.takePrimaryAuthenticationReconciliationHandoff(
+ cause: .chatGPTCancelOutcomeUnknown(
+ previousActiveAccountKey: session.previousActiveAccountKey
+ )
)
- )
+ ),
+ terminalPhase: nil
)
}
guard case .signIn = session.purpose else {
let failure = CodexReviewAuthenticationFailure.protocolViolation(
message: "An isolated add-account login cannot hand off primary authentication reconciliation."
)
- auth.updatePhase(.failed(failure))
- return .failed(failure)
- }
- return .primaryRuntimeReconciliation(
- session.takePrimaryAuthenticationReconciliationHandoff(
- cause: .chatGPTCommitted(reconciliationReason)
+ return .init(
+ terminal: .failed(failure),
+ terminalPhase: .failed(failure)
)
+ }
+ return .init(
+ terminal: .primaryRuntimeReconciliation(
+ session.takePrimaryAuthenticationReconciliationHandoff(
+ cause: .chatGPTCommitted(reconciliationReason)
+ )
+ ),
+ terminalPhase: nil
)
}
}
@@ -2516,13 +2556,15 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
private func finishSuccessfulLogin(
session: LoginSession,
auth: CodexReviewAuthModel
- ) async -> LoginSessionTerminal {
+ ) async -> LoginSessionResolution {
guard let loginRuntime = await session.runtime() else {
let failure = CodexReviewAuthenticationFailure.protocolViolation(
message: "Authentication completed without a bound login runtime."
)
- auth.updatePhase(.failed(failure))
- return .failed(failure)
+ return .init(
+ terminal: .failed(failure),
+ terminalPhase: .failed(failure)
+ )
}
var stagingURLRequiringRemoval: URL?
var deferredPrimaryExpectation = session.provider.successfulLoginExpectation
@@ -2641,10 +2683,11 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
isolatedProductCommitAuthorization: mutationLease
)
}
- if claimLoginResultPublication(
+ let publishesResult = claimLoginResultPublication(
for: session,
usesPrimaryRuntime: loginRuntime.usesPrimaryRuntime
- ) {
+ )
+ if publishesResult {
if let primaryObservationPublication {
primaryObservationPublication.session.updateAccountObservation(
primaryObservationPublication.observation,
@@ -2652,41 +2695,50 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
)
}
applyAccountRegistrySnapshot(reconciliation.persisted, to: auth)
- auth.updatePhase(.signedOut)
}
if let stagingURLRequiringRemoval {
await accountRegistry.finishTemporaryCodexHome(stagingURLRequiringRemoval)
}
- return .succeeded
+ return .init(
+ terminal: .succeeded,
+ terminalPhase: publishesResult ? .signedOut : nil
+ )
} catch {
if let stagingURLRequiringRemoval {
await accountRegistry.finishTemporaryCodexHome(stagingURLRequiringRemoval)
}
if error is IsolatedLoginProductCommitCancelled {
- auth.updatePhase(.signedOut)
- return .cancelled
+ return .init(
+ terminal: .cancelled,
+ terminalPhase: .signedOut
+ )
}
if let productCommitFailure = error as? IsolatedLoginProductCommitFailure {
- auth.updatePhase(.failed(productCommitFailure.failure))
- return .failed(productCommitFailure.failure)
+ return .init(
+ terminal: .failed(productCommitFailure.failure),
+ terminalPhase: .failed(productCommitFailure.failure)
+ )
}
if loginRuntime.usesPrimaryRuntime {
return await finishPrimaryLoginWithDeferredRegistryReconciliation(
session: session,
expectedAccount: deferredPrimaryExpectation,
- underlyingError: error,
- auth: auth
+ underlyingError: error
)
}
let failure = (error as? CodexReviewAuthenticationFailure)
?? CodexReviewAuthenticationFailure.login(message: error.localizedDescription)
switch await session.claimPreCommitFailure() {
case .cancel:
- auth.updatePhase(.signedOut)
- return .cancelled
+ return .init(
+ terminal: .cancelled,
+ terminalPhase: .signedOut
+ )
case .fail:
- auth.updatePhase(.failed(failure))
- return .failed(failure)
+ return .init(
+ terminal: .failed(failure),
+ terminalPhase: .failed(failure)
+ )
}
}
}
@@ -2725,9 +2777,8 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
private func finishPrimaryLoginWithDeferredRegistryReconciliation(
session: LoginSession,
expectedAccount: ExpectedRuntimeAccount,
- underlyingError: any Error,
- auth: CodexReviewAuthModel
- ) async -> LoginSessionTerminal {
+ underlyingError: any Error
+ ) async -> LoginSessionResolution {
let message = "Authentication succeeded, but account registry reconciliation remains pending: "
+ underlyingError.localizedDescription
do {
@@ -2740,12 +2791,15 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
"Committed primary authentication must durably record reconciliation debt: \(error.localizedDescription)"
)
}
- if commitPrimaryLoginReconciliationFailure(for: session) {
- auth.updatePhase(.failed(.accountCommit(message: message)))
+ let publishesFailure = commitPrimaryLoginReconciliationFailure(for: session)
+ if publishesFailure {
attachedStore?.transitionToFailed(message)
}
logger.error("\(message, privacy: .public)")
- return .committedNeedsRuntimeReconciliation(message: message)
+ return .init(
+ terminal: .committedNeedsRuntimeReconciliation(message: message),
+ terminalPhase: publishesFailure ? .failed(.accountCommit(message: message)) : nil
+ )
}
private func commitPrimaryLoginReconciliationFailure(
@@ -2761,55 +2815,65 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
}
private func finishCancelledLoginOutcome(
- reason: LoginTerminationReason,
- auth: CodexReviewAuthModel
- ) -> LoginSessionTerminal {
+ reason: LoginTerminationReason
+ ) -> LoginSessionResolution {
switch reason {
case .urlOpenFailure(let failure), .runtimeFailure(let failure):
- auth.updatePhase(.failed(failure))
- return .failed(failure)
+ return .init(
+ terminal: .failed(failure),
+ terminalPhase: .failed(failure)
+ )
case .storeStop:
- auth.updatePhase(.signedOut)
- return .stopped
+ return .init(
+ terminal: .stopped,
+ terminalPhase: .signedOut
+ )
case .rootOutcome, .explicitCancellation:
- auth.updatePhase(.signedOut)
- return .cancelled
+ return .init(
+ terminal: .cancelled,
+ terminalPhase: .signedOut
+ )
}
}
private func finishCancelledLoginWaiter(
session _: LoginSession,
reason: LoginTerminationReason,
- message: String?,
- auth: CodexReviewAuthModel
- ) -> LoginSessionTerminal {
+ message: String?
+ ) -> LoginSessionResolution {
finishLoginWaiterFailure(
reason: reason,
- message: message ?? "Authentication cancellation failed.",
- auth: auth
+ message: message ?? "Authentication cancellation failed."
)
}
private func finishLoginWaiterFailure(
reason: LoginTerminationReason,
- message: String,
- auth: CodexReviewAuthModel
- ) -> LoginSessionTerminal {
+ message: String
+ ) -> LoginSessionResolution {
switch reason {
case .rootOutcome:
let failure = CodexReviewAuthenticationFailure.login(message: message)
- auth.updatePhase(.failed(failure))
- return .failed(failure)
+ return .init(
+ terminal: .failed(failure),
+ terminalPhase: .failed(failure)
+ )
case .explicitCancellation:
let failure = CodexReviewAuthenticationFailure.runtime(message: message)
- auth.updatePhase(.failed(failure))
- return .failed(failure)
+ return .init(
+ terminal: .failed(failure),
+ terminalPhase: .failed(failure)
+ )
case .urlOpenFailure(let failure), .runtimeFailure(let failure):
- auth.updatePhase(.failed(failure))
- return .failed(failure)
+ return .init(
+ terminal: .failed(failure),
+ terminalPhase: .failed(failure)
+ )
case .storeStop:
- auth.updatePhase(.signedOut)
- return .stopped
+ return .init(
+ terminal: .stopped,
+ terminalPhase: .signedOut
+ )
}
}
@@ -2818,8 +2882,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
reservation: AccountRuntimeTransitionCoordinator.PrimaryReconciliationReservation,
auth: CodexReviewAuthModel,
oldRuntimeAlreadyStopped: Bool
- ) async {
+ ) async -> AccountRuntimeTransitionCoordinator.PrimaryReconciliationFinishAction {
let finalResult: PrimaryAuthenticationReconciliationResult
+ var terminalPhase: CodexReviewAuthModel.Phase?
+ var publishesRuntimeState = false
let expectedAccount: ExpectedRuntimeAccount = switch handoff.cause {
case .chatGPTCommitted:
.anyChatGPT
@@ -2843,11 +2909,13 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
if oldRuntimeAlreadyStopped == false {
await stop(store: store, purpose: .loginReconciliationPreservingRuns)
}
+ publishesRuntimeState = accountRuntimeTransitionCoordinator
+ .primaryPublicationClaim(reservation) == .published
guard await startRuntime(
store: store,
forceRestartIfNeeded: true,
expectedAccount: expectedAccount,
- mode: accountRuntimeTransitionCoordinator.primaryPublicationClaim(reservation) == .published
+ mode: publishesRuntimeState
? .published(owner: .primary(reservation))
: .quiescentReconciliation,
registryAuthorization: handoff.mutationLease,
@@ -2893,6 +2961,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
message: "Authentication reconciliation produced an invalid account observation."
)
}
+ if publishesRuntimeState {
+ terminalPhase = .signedOut
+ }
} catch {
let message = "Authentication was committed, but runtime reconciliation remains pending: \(error.localizedDescription)"
do {
@@ -2907,23 +2978,39 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend {
}
if accountRuntimeTransitionCoordinator.commitPrimaryReconciliationFailure(reservation) {
attachedStore?.transitionToFailed(message)
- auth.updatePhase(.failed(.accountCommit(message: message)))
+ terminalPhase = .failed(.accountCommit(message: message))
}
logger.error(
"Primary authentication reconciliation deferred after \(String(describing: handoff.cause), privacy: .public): \(message, privacy: .public)"
)
finalResult = .committedNeedsRuntimeReconciliation(message: message)
}
- if accountRuntimeTransitionCoordinator.isFinalShutdownRequested {
- auth.updatePhase(.signedOut)
- }
+ await authenticationOwnershipWillRelease?()
await accountRegistry.finishMutation(handoff.mutationLease)
- let didResolve = handoff.finalResult.resolve(finalResult)
- precondition(
- didResolve,
- "A primary authentication reconciliation resolver can complete only once."
- )
- clearActivePrimaryAuthenticationReconciliation(handoff)
+ return { @MainActor [self, weak auth] in
+ clearActivePrimaryAuthenticationReconciliation(handoff)
+ if let auth {
+ publishAuthenticationTerminalPhase(terminalPhase, to: auth)
+ }
+ let didResolve = handoff.finalResult.resolve(finalResult)
+ precondition(
+ didResolve,
+ "A primary authentication reconciliation resolver can complete only once."
+ )
+ }
+ }
+
+ private func publishAuthenticationTerminalPhase(
+ _ terminalPhase: CodexReviewAuthModel.Phase?,
+ to auth: CodexReviewAuthModel
+ ) {
+ let publishedTerminalPhase: CodexReviewAuthModel.Phase? =
+ accountRuntimeTransitionCoordinator.isFinalShutdownRequested
+ ? .signedOut
+ : terminalPhase
+ if let publishedTerminalPhase {
+ auth.updatePhase(publishedTerminalPhase)
+ }
}
private func installActivePrimaryAuthenticationReconciliation(
diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift
index 29c01de..830c8b0 100644
--- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift
+++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift
@@ -24,6 +24,7 @@ private extension CodexReviewStore {
networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default,
appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil,
authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? = nil,
+ authenticationOwnershipWillRelease: CodexReviewAuthenticationOwnershipWillRelease? = nil,
authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? = nil,
authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? = nil,
authenticationOperationDidBind: CodexReviewAuthenticationOperationDidBind? = nil,
@@ -45,6 +46,7 @@ private extension CodexReviewStore {
networkRecoveryPolicy: networkRecoveryPolicy,
appServerLifecycleHandler: appServerLifecycleHandler,
authenticationMutationDidBegin: authenticationMutationDidBegin,
+ authenticationOwnershipWillRelease: authenticationOwnershipWillRelease,
authenticationCancellationDidRequest: authenticationCancellationDidRequest,
authenticationProductCommitDidApply: authenticationProductCommitDidApply,
authenticationOperationDidBind: authenticationOperationDidBind,
@@ -83,6 +85,7 @@ private extension CodexReviewStore {
networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default,
appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil,
authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? = nil,
+ authenticationOwnershipWillRelease: CodexReviewAuthenticationOwnershipWillRelease? = nil,
authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? = nil,
authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? = nil,
authenticationOperationDidBind: CodexReviewAuthenticationOperationDidBind? = nil,
@@ -105,6 +108,7 @@ private extension CodexReviewStore {
networkRecoveryPolicy: networkRecoveryPolicy,
appServerLifecycleHandler: appServerLifecycleHandler,
authenticationMutationDidBegin: authenticationMutationDidBegin,
+ authenticationOwnershipWillRelease: authenticationOwnershipWillRelease,
authenticationCancellationDidRequest: authenticationCancellationDidRequest,
authenticationProductCommitDidApply: authenticationProductCommitDidApply,
authenticationOperationDidBind: authenticationOperationDidBind,
@@ -148,6 +152,7 @@ struct CodexReviewHostTests {
let coordinator = AccountRuntimeTransitionCoordinator()
var admittedLogin: AccountRuntimeTransitionCoordinator.LoginAdmission?
var didAdmitLogin = false
+ var didRunFinishAction = false
coordinator.installDidBecomeIdle {
guard didAdmitLogin == false else { return }
didAdmitLogin = true
@@ -155,13 +160,19 @@ struct CodexReviewHostTests {
}
let reconciliationCompleted = CompletionFlag()
let reconciliation = Task { @MainActor in
- await coordinator.performStoppedPrimaryReconciliation { _ in }
+ await coordinator.performStoppedPrimaryReconciliation { _ in
+ return {
+ #expect(didAdmitLogin == false)
+ didRunFinishAction = true
+ }
+ }
await reconciliationCompleted.complete()
}
try #require(await waitUntil(timeout: .seconds(2)) {
await reconciliationCompleted.isCompleted()
})
+ #expect(didRunFinishAction)
#expect(coordinator.hasActiveLoginTransition)
coordinator.finishLoginAdmission(try #require(admittedLogin))
@@ -1426,6 +1437,67 @@ struct CodexReviewHostTests {
try await assertUnknownAPIKeyLoginOutcome(committed: false)
}
+ @Test func liveStoreFinalShutdownSupersedesPrimaryReconciliationFailureAtOwnershipRelease() async throws {
+ let homeURL = try temporaryHome()
+ let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true)
+ let initialTransport = FakeCodexAppServerTransport()
+ try await initialTransport.enqueueAccount(nil, requiresOpenAIAuth: false)
+ try await initialTransport.enqueueConfiguration(try makeHostConfigurationReadResult())
+ try await initialTransport.enqueueModels(.init(models: []))
+ try await initialTransport.enqueueChatGPTLogin(
+ loginID: "final-shutdown-unknown-api-key-response",
+ authenticationURL: testAuthenticationURL
+ )
+ let authenticationOwnershipReleaseGate = CodexAppServerTestGate()
+ let finalShutdownRequested = OneShotSignal()
+ var runtimeFactoryCallCount = 0
+ let store = CodexReviewStore.makeLiveStoreForTesting(
+ environment: ["HOME": homeURL.path],
+ authenticationOwnershipWillRelease: {
+ await authenticationOwnershipReleaseGate.waitIgnoringCancellation()
+ },
+ finalShutdownDidRequest: {
+ await finalShutdownRequested.signal()
+ },
+ transportFactory: { runtimeHomeURL in
+ #expect(runtimeHomeURL == codexHomeURL)
+ runtimeFactoryCallCount += 1
+ guard runtimeFactoryCallCount == 1 else {
+ throw CodexReviewAPI.Error.io("replacement validation runtime unavailable")
+ }
+ return initialTransport
+ }
+ )
+ await store.start(forceRestartIfNeeded: true)
+ let sentinel = "test-secret-final-shutdown-api-key-outcome"
+ try writeAPIKeyAuth(
+ Data("{\"OPENAI_API_KEY\":\"\(sentinel)\"}".utf8),
+ to: codexHomeURL
+ )
+
+ let login = Task { @MainActor in
+ try await store.signIn(using: .apiKey(try CodexReviewAPIKey(validating: sentinel)))
+ }
+ await authenticationOwnershipReleaseGate.waitUntilBlocked()
+ #expect(store.auth.isAuthenticating)
+
+ let stop = Task { @MainActor in
+ await store.stop()
+ }
+ await finalShutdownRequested.wait()
+ await authenticationOwnershipReleaseGate.open()
+ try await login.value
+ await stop.value
+
+ #expect(store.auth.isAuthenticating == false)
+ #expect(store.auth.errorMessage == nil)
+ #expect(store.serverState == .stopped)
+ #expect(runtimeFactoryCallCount == 2)
+ #expect(FileManager.default.fileExists(
+ atPath: accountReconciliationDebtURL(homeURL: homeURL).path
+ ))
+ }
+
@Test func liveStoreDiscardsIsolatedRuntimeWhenAPIKeyOutcomeIsUnknown() async throws {
let homeURL = try temporaryHome()
let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true)
@@ -3854,10 +3926,14 @@ struct CodexReviewHostTests {
var nonPrimaryTransports = [authTransport, refreshTransport]
var nonPrimaryRuntimeIndex = 0
var refreshCodexHomeURL: URL?
+ let authenticationOwnershipReleaseGate = CodexAppServerTestGate()
let externalURLOpener = FakeExternalURLOpener()
let store = CodexReviewStore.makeLiveStoreForTesting(
environment: ["HOME": homeURL.path],
externalURLOpener: externalURLOpener.open,
+ authenticationOwnershipWillRelease: {
+ await authenticationOwnershipReleaseGate.waitIgnoringCancellation()
+ },
transportFactory: { codexHomeURL in
if codexHomeURL == mainCodexHomeURL {
return mainTransport
@@ -3894,6 +3970,9 @@ struct CodexReviewHostTests {
authMode: .chatGPT,
planType: .plus
))
+ await authenticationOwnershipReleaseGate.waitUntilBlocked()
+ #expect(store.auth.isAuthenticating)
+ await authenticationOwnershipReleaseGate.open()
#expect(await waitUntil(timeout: .seconds(1)) {
store.auth.persistedAccounts.contains { $0.accountKey == "new@example.com" }
&& store.auth.persistedAccounts.first { $0.accountKey == "new@example.com" }?.rateLimits.first?.usedPercent == 25
@@ -6617,10 +6696,14 @@ private func assertUnknownAPIKeyLoginOutcome(committed: Bool) async throws {
try await reconciliationTransport.enqueueModels(.init(models: []))
}
var transports = [initialTransport, reconciliationTransport]
+ let authenticationOwnershipReleaseGate = CodexAppServerTestGate()
let externalURLOpener = FakeExternalURLOpener()
let store = CodexReviewStore.makeLiveStoreForTesting(
environment: ["HOME": homeURL.path],
externalURLOpener: externalURLOpener.open,
+ authenticationOwnershipWillRelease: {
+ await authenticationOwnershipReleaseGate.waitIgnoringCancellation()
+ },
transportFactory: { runtimeHomeURL in
#expect(runtimeHomeURL == codexHomeURL)
return transports.removeFirst()
@@ -6633,7 +6716,14 @@ private func assertUnknownAPIKeyLoginOutcome(committed: Bool) async throws {
to: codexHomeURL
)
- try await store.signIn(using: .apiKey(try CodexReviewAPIKey(validating: sentinel)))
+ let login = Task { @MainActor in
+ try await store.signIn(using: .apiKey(try CodexReviewAPIKey(validating: sentinel)))
+ }
+ await authenticationOwnershipReleaseGate.waitUntilBlocked()
+ #expect(store.auth.isAuthenticating)
+ await authenticationOwnershipReleaseGate.open()
+ try await login.value
+ #expect(store.auth.isAuthenticating == false)
#expect(externalURLOpener.openedURLs.isEmpty)
#expect(transports.isEmpty)
From 68cede191118265aa7112efd9740e4a4f9e87df0 Mon Sep 17 00:00:00 2001
From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com>
Date: Mon, 10 Aug 2026 08:36:14 +0900
Subject: [PATCH 5/5] test(app-server): await review item delivery
---
.../CodexAppServerKitTests.swift | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift
index 5d55aa4..1123ebe 100644
--- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift
+++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift
@@ -2342,10 +2342,18 @@ struct CodexAppServerKitTests {
var eventIterator = review.events.makeAsyncIterator()
let initialEvent = try #require(try await eventIterator.next())
#expect(initialEvent == .snapshot(review.initialTurn))
+ let completedItemsObserved = CodexAppServerTestGate()
let eventsTask = Task {
var events = [initialEvent]
+ var completedItemIDs: Set = []
while let event = try await eventIterator.next() {
events.append(event)
+ if case .itemCompleted(let item, _) = event {
+ completedItemIDs.insert(item.id)
+ if completedItemIDs == ["command-1", "reasoning-1", "tool-1", "file-1"] {
+ await completedItemsObserved.open()
+ }
+ }
}
return events
}
@@ -2410,6 +2418,9 @@ struct CodexAppServerKitTests {
)
)
)
+ try await withTimeout {
+ try await completedItemsObserved.wait()
+ }
try await transport.emitServerNotification(
method: "turn/completed",
params: TurnCompletedParams(