From 29f345154b4f11f3d93f7d08e146df08449afb26 Mon Sep 17 00:00:00 2001 From: MP Date: Wed, 15 Jul 2026 22:23:18 +0800 Subject: [PATCH 01/21] fix: separate Codex launch and worktree paths --- .../Fixtures/test-suite-contract-ledger.tsv | 8 + .../Codex/CodexAgentModeCoordinator.swift | 71 ++-- .../Codex/CodexRuntimeWorkspacePaths.swift | 54 +++ ...gentWorktreeRuntimeWorkspaceResolver.swift | 67 +++- .../AgentModeViewModel+TabSession.swift | 7 +- .../ViewModels/AgentModeViewModel.swift | 43 ++- .../Stress/AgentChatStressHarness.swift | 2 +- .../AppServer/CodexAppServerClient.swift | 52 +-- .../CodexNativeSessionController.swift | 48 +-- .../AI/Providers/Codex/CodexCLIProvider.swift | 2 +- ...veSessionControllerTurnDispatchTests.swift | 2 +- .../Codex/CodexFallbackFIFOTests.swift | 2 +- ...eSessionControllerEventRecoveryTests.swift | 4 +- ...tiveSessionControllerGoalConfigTests.swift | 143 +++++++- .../CodexSessionControllerTestDefaults.swift | 92 +++++ .../MCP/AgentRunWorktreeStartTests.swift | 343 ++++++++++++++---- 16 files changed, 756 insertions(+), 184 deletions(-) create mode 100644 Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexRuntimeWorkspacePaths.swift diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index 9d1231d71..7c6c7b057 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -386,6 +386,11 @@ root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionMaterialize root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionValidatesAndMaterializesChangedSecondaryBindingWithoutRestartingPrimaryIdentity root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testBindingTransitionValidatesAndMaterializesChangedSecondaryBindingWithoutRestartingPrimaryIdentity MCP agent.worktree.binding-transition.secondary behavioral_regression root_swiftpm routine 2 AgentRunWorktreeStartLifecycleFixture Unavailable secondary binding rejects atomically; valid changed secondary materializes both physical roots without restarting provider or Codex identity high 0.804500 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture consolidated_replacement 0 replaces root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionMaterializesChangedSecondaryBindingWithoutRestartingUnchangedPrimaryIdentity; root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionRejectsUnavailableSecondaryBindingEvenWhenPrimaryIdentityIsUnchanged root/RepoPromptTests.AgentRunWorktreeStartTests/testCanonicalAgentRunReviewSourceStagesBindsToFreshWorktreeChildAndCleansUp root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCanonicalAgentRunReviewSourceStagesBindsToFreshWorktreeChildAndCleansUp MCP agent.oracle_review.canonical_run_binding launch_snapshot,exact_run,cleanup deterministic_lifecycle_integration root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture Canonical source packaging stages for an exact fresh worktree child run with an independent target binding and disappears on control deactivation. A stale or sibling child run could consume source-owned review packaging. 0.107000 suite_owned_temp_roots;awaited_window_teardown WindowStatesManager AgentRunWorktreeStartLifecycleFixture retain 0 Issue #264 exact-ID ledger reconciliation root/RepoPromptTests.AgentRunWorktreeStartTests/testChildSessionWorktreeBindingInheritanceCanBeOptedOut root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testChildSessionWorktreeBindingInheritanceCanBeOptedOut MCP unreviewed unreviewed root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture unreviewed unreviewed 0.003000 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain_pending_review 0 initial census source line 76 +root/RepoPromptTests.AgentRunWorktreeStartTests/testCodexControllerFailsClosedWhenInstalledLaunchRootBecomesMalformed root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCodexControllerFailsClosedWhenInstalledLaunchRootBecomesMalformed MCP agent.worktree.codex-controller-resolution-failure-teardown codex_launch_execution_split lifecycle_regression root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture;ReplacementIdentityFakeCodexController Installed controller identity fields, event task, run ID, reconnect state, and shutdown count after a selected logical root becomes malformed A workspace resolution failure could leave the installed Codex controller reusable and its event stream alive 0.020000 suite_owned_temp_roots;suite_owned_immutable_git_seed GlobalSettingsStore.globalCodeMapsDisabled;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 issue #554 WI-1 fail-closed installed-controller teardown +root/RepoPromptTests.AgentRunWorktreeStartTests/testCodexControllerReplacementKeysOnExecutionChangeAndSurvivesSecondaryBindingChanges root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCodexControllerReplacementKeysOnExecutionChangeAndSurvivesSecondaryBindingChanges MCP agent.worktree.codex-controller-replacement-key codex_launch_execution_split lifecycle_regression root_swiftpm routine 4 AgentRunWorktreeStartLifecycleFixture;ReplacementIdentityFakeCodexController Factory invocation pairs and controller ObjectIdentifier across ensure passes for creation, unchanged pair, secondary-binding change, and execution-directory change Codex controller could be recycled on secondary-binding churn or kept alive across an execution-directory change 0.088000 suite_owned_temp_roots;suite_owned_immutable_git_seed GlobalSettingsStore.globalCodeMapsDisabled;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 issue #554 WI-1 Codex launch/execution path pair coverage +root/RepoPromptTests.AgentRunWorktreeStartTests/testCodexControllerReplacementKeysOnLaunchOnlyChange root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCodexControllerReplacementKeysOnLaunchOnlyChange MCP agent.worktree.codex-controller-replacement-launch-only codex_launch_execution_split lifecycle_regression root_swiftpm routine 2 AgentRunWorktreeStartLifecycleFixture;ReplacementIdentityFakeCodexController Factory invocation pairs and controller ObjectIdentifier across a logical-root-only binding change with unchanged execution directory A launch-directory-only change could reuse a Codex controller whose app-server was launched from the wrong root 0.038000 suite_owned_temp_roots;suite_owned_immutable_git_seed GlobalSettingsStore.globalCodeMapsDisabled;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 issue #554 WI-1 Codex launch/execution path pair coverage +root/RepoPromptTests.AgentRunWorktreeStartTests/testCodexRuntimeWorkspacePathsFailsClosedOnMalformedSelectedLogicalRoot root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCodexRuntimeWorkspacePathsFailsClosedOnMalformedSelectedLogicalRoot MCP agent.worktree.codex-launch-root-validation codex_launch_execution_split protocol_negative root_swiftpm routine 3 AgentRunWorktreeStartLifecycleFixture Typed emptyLogicalRoot and launchDirectoryUnavailable errors plus the nil/nil fallback pair A malformed selected logical root could reach process spawn as an opaque launch failure instead of a pre-startup typed error 0.001000 suite_owned_temp_roots;suite_owned_immutable_git_seed AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 issue #554 WI-1 Codex launch/execution path pair coverage +root/RepoPromptTests.AgentRunWorktreeStartTests/testCodexRuntimeWorkspacePathsProjectionCoversBoundUnboundSecondaryAndMissingWorktree root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCodexRuntimeWorkspacePathsProjectionCoversBoundUnboundSecondaryAndMissingWorktree MCP agent.worktree.codex-path-pair-projection codex_launch_execution_split deterministic_service_contract root_swiftpm routine 5 AgentRunWorktreeStartLifecycleFixture Launch/execution pair equality across unbound, bound, secondary-binding, secondary-only, and missing-worktree projections Worktree-bound Codex sessions could launch the app-server from the worktree or move execution off the bound worktree 0.003000 suite_owned_temp_roots;suite_owned_immutable_git_seed AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 issue #554 WI-1 Codex launch/execution path pair coverage root/RepoPromptTests.AgentRunWorktreeStartTests/testCoordinatorCreateCarriesReceiptIntoEligibleOwnershipPreparation root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCoordinatorCreateCarriesReceiptIntoEligibleOwnershipPreparation MCP worktree_receipt.coordinator_production_chain loaded_root_admission,production_coordinator,receipt_hint,binding_transition,ownership_generation,session_correlation,no_noReceipt deterministic_integration root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture The production start coordinator creates the worktree receipt, binds its exact hint into the real transition, and commits eligible ownership with matching session, generation, and correlation and no noReceipt fallback. Coordinator plumbing could drop or rewrite valid creation evidence between Git creation and ownership preparation, forcing a full-crawl fallback despite an admitted base. 0.841000 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown;user_defaults GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;UserDefaults.standard;GitWorkspaceStateAuthority.shared AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture;test_case+target_discard+window_teardown+defaults_restore retain 0 P1 production-chain replacement for the former manually assembled receipt-to-materializer test. root/RepoPromptTests.AgentRunWorktreeStartTests/testCoordinatorCreateFromLoadedLinkedBaseCarriesReceiptIntoEligibleServing root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCoordinatorCreateFromLoadedLinkedBaseCarriesReceiptIntoEligibleServing MCP worktree_receipt.linked_base_coordinator_chain linked_base,admitted_snapshot,receipt,hint,binding_match,owner_generation,diff_seed_serving deterministic_integration root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture The coordinator carries linked-base receipt evidence into one exact binding hint and publishes one seeded child with no noReceipt or full crawl. Coordinator transport could drop a valid linked-base receipt and force a safe but costly full crawl. 2.083000 git_subprocess;fsevents;suite_owned_temp_roots WindowStatesManager;GitWorkspaceStateAuthority.shared;instrumentation_lock test_case+target_discard+window_teardown+reset retain 0 Direct linked-base coordinator contrast for the full service regression. root/RepoPromptTests.AgentRunWorktreeStartTests/testCoordinatorPostCreateFailureRecordsOneTerminalReceiptDecision root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCoordinatorPostCreateFailureRecordsOneTerminalReceiptDecision MCP worktree_receipt.coordinator_post_create_failure_terminal git_creation,coordinator_throw,retained_worktree,no_binding,exactly_once,terminal protocol_negative root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture An injected coordinator error after Git creation leaves the created worktree unbound and terminalizes the single correlated receipt attempt at coordinator stage without ambiguity. Post-create coordinator failure could leave a pending diagnostic forever or overwrite creation evidence during rethrow. 0.859500 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;GitWorkspaceStateAuthority.shared;instrumentation_lock AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 P1 deterministic post-create coordinator terminalization repair. @@ -403,6 +408,7 @@ root/RepoPromptTests.AgentRunWorktreeStartTests/testRecoverableStartAbortBeforeT root/RepoPromptTests.AgentRunWorktreeStartTests/testReviewSourceStagingRejectsParentMutationAfterTargetCreation root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testReviewSourceStagingRejectsParentMutationAfterTargetCreation MCP agent.oracle_review.parent_lineage_freeze expected_parent,parent_mutation,staging,fail_closed deterministic_lifecycle_integration root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture Staging compares the target's current parent with the launch-time expected parent and records a fail-closed mismatch after intervening mutation. A target reparented during launch awaits could consume review authority intended for a different child lineage. 0.044500 suite_owned_temp_roots;awaited_window_teardown WindowStatesManager AgentRunWorktreeStartLifecycleFixture retain 0 Issue #264 launch-boundary review correction root/RepoPromptTests.AgentRunWorktreeStartTests/testSharedStartWorktreeCoordinatorHonorsPreCancelledCreateWithoutMutation root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testSharedStartWorktreeCoordinatorHonorsPreCancelledCreateWithoutMutation MCP unreviewed unreviewed root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture unreviewed unreviewed 0.431000 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain_pending_review 0 initial census source line 847 root/RepoPromptTests.AgentRunWorktreeStartTests/testStartedThreadLocationChangesPreserveConfirmationIdentityAndPendingHandoffContracts root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testStartedThreadLocationChangesPreserveConfirmationIdentityAndPendingHandoffContracts MCP agent.ui.location-change.started-thread behavioral_regression root_swiftpm routine 3 AgentRunWorktreeStartLifecycleFixture Restart confirmation, identity clearing, local/new/existing bindings, effective paths, and stable unstaged pending handoff across sequential idle changes high 1.445000 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture consolidated_replacement 0 replaces root/RepoPromptTests.AgentRunWorktreeStartTests/testIdleStartedThreadRequiresRestartConfirmationBeforeReturningLocalAndClearsOldCWDProviderIdentity; root/RepoPromptTests.AgentRunWorktreeStartTests/testStartedThreadCanCreateNewWorktreeAndReplacePrimaryBinding; root/RepoPromptTests.AgentRunWorktreeStartTests/testStartedThreadCanSwitchAgainWhileRecoveryHandoffWaitsForNextSend +root/RepoPromptTests.AgentRunWorktreeStartTests/testWorktreeCodexControllerFakesPreserveDistinctStreamAndShutdownSemantics root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testWorktreeCodexControllerFakesPreserveDistinctStreamAndShutdownSemantics MCP agent.worktree.codex-controller-test-double-lifecycle finished_stream,open_stream,shutdown lifecycle_regression root_swiftpm routine 3 WorktreeStartFakeCodexController;ReplacementIdentityFakeCodexController The passive fake stream finishes immediately, the replacement fake stream remains open before shutdown, and shutdown increments its count before finishing the stream. Shared test defaults could mask distinct stream and shutdown semantics, invalidating controller replacement and teardown coverage. test_case retain 0 Test-double lifecycle contract keeps event-stream and shutdown behavior explicit outside passive defaults. root/RepoPromptTests.AgentRuntimeSidebarViewModelTests/testClaudeFableSelectionFallsBackToOneMillionTokenContextWindow root Tests/RepoPromptTests/AgentMode/AgentRuntimeSidebarViewModelTests.swift RepoPromptTests.AgentRuntimeSidebarViewModelTests testClaudeFableSelectionFallsBackToOneMillionTokenContextWindow AgentMode unreviewed unreviewed root_swiftpm scale 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 103 root/RepoPromptTests.AgentRuntimeSidebarViewModelTests/testCustomSlotMappingUsesBackendContextWindowFallback root Tests/RepoPromptTests/AgentMode/AgentRuntimeSidebarViewModelTests.swift RepoPromptTests.AgentRuntimeSidebarViewModelTests testCustomSlotMappingUsesBackendContextWindowFallback AgentMode agent_runtime.sidebar.custom_backend_context_window custom_claude_compatible,context_window,effort_encoding pure_view_model root_swiftpm routine 2 AgentRuntimeMetricsUIStoreFixture Custom backend sonnet:xhigh reports a 1M fallback while haiku falls back to the standard context window. Runtime sidebar token limits could mislead users for custom Claude-compatible slot mappings. 0.003000 test_case retain 0 PR #252 ledger reconciliation for custom context-window fallback root/RepoPromptTests.AgentRuntimeSidebarViewModelTests/testEncodedClaudeEffortSelectionResolvesContextWindowFallback root Tests/RepoPromptTests/AgentMode/AgentRuntimeSidebarViewModelTests.swift RepoPromptTests.AgentRuntimeSidebarViewModelTests testEncodedClaudeEffortSelectionResolvesContextWindowFallback AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 117 @@ -1159,6 +1165,8 @@ root/RepoPromptTests.CodexNativeSessionControllerGoalConfigTests/testInitialized root/RepoPromptTests.CodexNativeSessionControllerGoalConfigTests/testNativeSessionControllerDefaultOptionsOmitProcessReasoningSummaryOverride root Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift RepoPromptTests.CodexNativeSessionControllerGoalConfigTests testNativeSessionControllerDefaultOptionsOmitProcessReasoningSummaryOverride AgentMode codex_native_session_controller_goal_config.native_session_controller_default_options_omit_process_reasoning_summary_override configuration_regression root_swiftpm routine 1 CodexNativeSessionControllerGoalConfigTests asserts native session controller default options omit process reasoning summary override with exact state, configuration, or formatted-output expectations. Provider/model configuration could serialize the wrong reasoning-summary/default option and change launched agent behavior. 0.261000 test_suite_ledger_stabilization_2026-07-03 retain 0 Gate 0 exact-ID stabilization 2026-07-03: live method added from authoritative conductor list; metadata intentionally minimal and suite-scoped. root/RepoPromptTests.CodexNativeSessionControllerGoalConfigTests/testOptionalMemoryModeRetriesDoNotFailStartup root Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift RepoPromptTests.CodexNativeSessionControllerGoalConfigTests testOptionalMemoryModeRetriesDoNotFailStartup AgentMode codex.app_server.memory_mode_best_effort startup_readiness,background_retry async_state_machine root_swiftpm routine 2 Startup succeeds despite ignored memoryMode/set responses, records bounded foreground attempts, and a background retry lands after readiness. high 2.000000 test_case retain 0 Codex app-server startup stall fix: optional memory-mode disable is best effort and cannot hold startup readiness. root/RepoPromptTests.CodexNativeSessionControllerGoalConfigTests/testPathOnlyResumeFailsLocallyBeforeWritingRequest root Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift RepoPromptTests.CodexNativeSessionControllerGoalConfigTests testPathOnlyResumeFailsLocallyBeforeWritingRequest AgentMode codex.app_server.request_conformance initialize,resume,json_rpc protocol_contract root_swiftpm routine 1 Request frame shape or compatibility error text matches the modern app-server contract. Malformed initialization or resume requests could prevent Codex sessions from starting. 0.002000 test_case retain 0 Codex app-server modernization Items 1–3 integration coverage. +root/RepoPromptTests.CodexNativeSessionControllerGoalConfigTests/testProcessLaunchDirectoryUpdateKeepsRunningTransportAndAppliesAfterRestart root Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift RepoPromptTests.CodexNativeSessionControllerGoalConfigTests testProcessLaunchDirectoryUpdateKeepsRunningTransportAndAppliesAfterRestart AgentMode codex.app_server.process_launch_directory_next_start running_transport,next_start,working_directory process_lifecycle root_swiftpm routine 2 fake_codex_app_server Changing the configured launch directory preserves the running PID and transport generation, then the same client records the new working directory after an explicit stop and restart. Reconstructing Config or coupling directory updates to restart policy could terminate active sessions or launch the next app-server from a stale directory. subprocess test_case retain 0 Client-level lifecycle coverage for launch-directory next-start behavior. +root/RepoPromptTests.CodexNativeSessionControllerGoalConfigTests/testProcessLaunchPolicyUpdateRestartsOnlyForEffectiveChanges root Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift RepoPromptTests.CodexNativeSessionControllerGoalConfigTests testProcessLaunchPolicyUpdateRestartsOnlyForEffectiveChanges AgentMode codex.app_server.process_launch_policy_effective_change feature_policy,reasoning_summary,explicit_stop process_lifecycle root_swiftpm routine 3 fake_codex_app_server An identical policy preserves PID and generation, while feature-policy and reasoning-summary changes each stop the running transport with explicitStop. Mechanical Config mutation could accidentally restart unchanged sessions or fail to restart when process launch policy changes. subprocess test_case retain 0 Client-level lifecycle coverage for effective launch-policy restart behavior. root/RepoPromptTests.CodexNativeSessionControllerGoalConfigTests/testProtocolShapeRejectionsPreserveMessageAndAdviseCLIUpdate root Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift RepoPromptTests.CodexNativeSessionControllerGoalConfigTests testProtocolShapeRejectionsPreserveMessageAndAdviseCLIUpdate AgentMode codex.app_server.request_conformance initialize,resume,json_rpc protocol_contract root_swiftpm routine 4 Request frame shape or compatibility error text matches the modern app-server contract. Malformed initialization or resume requests could prevent Codex sessions from starting. 0.000500 test_case retain 0 Codex app-server modernization Items 1–3 integration coverage. root/RepoPromptTests.CodexNativeSessionControllerGoalConfigTests/testResumeRequiresThreadIDAndIncludesOptionalPath root Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift RepoPromptTests.CodexNativeSessionControllerGoalConfigTests testResumeRequiresThreadIDAndIncludesOptionalPath AgentMode codex.app_server.request_conformance initialize,resume,json_rpc protocol_contract root_swiftpm routine 1 Request frame shape or compatibility error text matches the modern app-server contract. Malformed initialization or resume requests could prevent Codex sessions from starting. 0.228000 test_case retain 0 Codex app-server modernization Items 1–3 integration coverage. root/RepoPromptTests.CodexNativeSessionControllerGoalConfigTests/testResumeWithoutPathSendsRequiredThreadIDOnly root Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift RepoPromptTests.CodexNativeSessionControllerGoalConfigTests testResumeWithoutPathSendsRequiredThreadIDOnly AgentMode codex.app_server.request_conformance initialize,resume,json_rpc protocol_contract root_swiftpm routine 1 Request frame shape or compatibility error text matches the modern app-server contract. Malformed initialization or resume requests could prevent Codex sessions from starting. 0.244000 test_case retain 0 Codex app-server modernization Items 1–3 integration coverage. diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift index aa590ca21..3490ad554 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift @@ -10,7 +10,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { _ runID: UUID, _ tabID: UUID, _ windowID: Int, - _ workspacePath: String?, + _ workspacePaths: CodexRuntimeWorkspacePaths, _ permissionProfile: AgentModeViewModel.AgentPermissionProfile, _ taskLabelKind: AgentModelCatalog.TaskLabelKind?, _ computerUseEnabled: Bool @@ -214,7 +214,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { private var terminalCommitBarrier: AgentRunTerminalCommitBarrier? private var toolTrackingByTabID: [UUID: AgentToolTrackingController] = [:] private let windowID: Int - private let workspacePathProvider: (AgentModeViewModel.TabSession) throws -> String? + private let runtimeWorkspacePathsProvider: (AgentModeViewModel.TabSession) throws -> CodexRuntimeWorkspacePaths private let codexControllerFactory: CodexControllerFactory private let connectionPolicyInstaller: ConnectionPolicyInstaller private let shouldManageCodexTooling: Bool @@ -327,7 +327,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { init( windowID: Int, - workspacePathProvider: @escaping (AgentModeViewModel.TabSession) throws -> String?, + runtimeWorkspacePathsProvider: @escaping (AgentModeViewModel.TabSession) throws -> CodexRuntimeWorkspacePaths, codexControllerFactory: @escaping CodexControllerFactory, connectionPolicyInstaller: @escaping ConnectionPolicyInstaller, shouldManageCodexTooling: Bool, @@ -347,7 +347,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { initialLastUsedReasoningEffortsByModelSlug: [String: CodexReasoningEffort] = [:] ) { self.windowID = windowID - self.workspacePathProvider = workspacePathProvider + self.runtimeWorkspacePathsProvider = runtimeWorkspacePathsProvider self.codexControllerFactory = codexControllerFactory self.connectionPolicyInstaller = connectionPolicyInstaller self.shouldManageCodexTooling = shouldManageCodexTooling @@ -2654,12 +2654,8 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { if let controller = session.codexController { Task { await controller.shutdown() } } - session.codexController = nil - session.codexControllerPermissionProfile = nil - session.codexControllerTaskLabelKind = nil - session.codexControllerWorkspacePath = nil + clearCodexControllerInstanceState(for: session) session.pendingCodexComputerUseActivation = nil - session.codexControllerFeatureState = nil session.codexEventTask?.cancel() session.codexEventTask = nil session.codexEventTaskRunID = nil @@ -3500,6 +3496,19 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { return "feature-state-unknown" } + /// Clears the five correlated fields that describe one installed Codex controller instance. + /// `codexController` clears first: its `didSet` rotates the controller generation and + /// invalidates turn identities before the creation metadata goes away. Caller-specific + /// lifecycle work — shutdown, event tasks, run IDs, reconnect flags, pending interactions, + /// tracking — stays with each teardown path. + private func clearCodexControllerInstanceState(for session: AgentModeViewModel.TabSession) { + session.codexController = nil + session.codexControllerPermissionProfile = nil + session.codexControllerTaskLabelKind = nil + session.codexControllerWorkspacePaths = nil + session.codexControllerFeatureState = nil + } + @discardableResult private func invalidateCodexControllerForReconnect( session: AgentModeViewModel.TabSession, @@ -3535,11 +3544,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { session: session, reason: "Codex queued follow-up was cancelled because the controller was replaced." ) - session.codexController = nil - session.codexControllerPermissionProfile = nil - session.codexControllerTaskLabelKind = nil - session.codexControllerWorkspacePath = nil - session.codexControllerFeatureState = nil + clearCodexControllerInstanceState(for: session) if !preserveRunID { session.runID = nil } @@ -3727,11 +3732,19 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { } let currentTaskLabelKind = session.mcpControlContext?.taskLabelKind - let runtimeWorkspacePath: String? + let runtimeWorkspacePaths: CodexRuntimeWorkspacePaths do { - runtimeWorkspacePath = try workspacePathProvider(session) + runtimeWorkspacePaths = try runtimeWorkspacePathsProvider(session) } catch { + let controllerToShutdown = session.codexController await failCodexStartupForWorkspaceResolution(session: session, error: error) + if let controllerToShutdown { + session.codexEventTask?.cancel() + session.codexEventTask = nil + session.codexEventTaskRunID = nil + clearCodexControllerInstanceState(for: session) + await controllerToShutdown.shutdown() + } return } let wantsGoalSupport = CodexGoalSupport.isEnabled @@ -3758,8 +3771,10 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { ) ) } + // A controller with no recorded pair keys as nil/nil — the shape of a session without + // any workspace — so it is only replaced when the runtime pair actually differs. if let existingController = session.codexController, - session.codexControllerWorkspacePath != runtimeWorkspacePath + session.codexControllerWorkspacePaths ?? .uniform(nil) != runtimeWorkspacePaths { _ = invalidateCodexControllerForReconnect( session: session, @@ -3802,7 +3817,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { runID, session.tabID, windowID, - runtimeWorkspacePath, + runtimeWorkspacePaths, session.permissionProfile, currentTaskLabelKind, wantsComputerUse @@ -3810,7 +3825,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { session.codexController = controller session.codexControllerPermissionProfile = session.permissionProfile session.codexControllerTaskLabelKind = currentTaskLabelKind - session.codexControllerWorkspacePath = runtimeWorkspacePath + session.codexControllerWorkspacePaths = runtimeWorkspacePaths session.codexControllerFeatureState = desiredFeatureState } guard let controller = session.codexController else { return nil } @@ -8116,11 +8131,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { if let controller = session.codexController { Task { await controller.shutdown() } } - session.codexController = nil - session.codexControllerPermissionProfile = nil - session.codexControllerTaskLabelKind = nil - session.codexControllerWorkspacePath = nil - session.codexControllerFeatureState = nil + clearCodexControllerInstanceState(for: session) session.codexEventTask?.cancel() session.codexEventTask = nil session.codexEventTaskRunID = nil @@ -8195,11 +8206,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { cancelCodexTransportClosedFallback(for: session.tabID) stopCodexStallWatchdog(for: session.tabID) stopBashLivenessTask(for: session.tabID) - session.codexController = nil - session.codexControllerPermissionProfile = nil - session.codexControllerTaskLabelKind = nil - session.codexControllerWorkspacePath = nil - session.codexControllerFeatureState = nil + clearCodexControllerInstanceState(for: session) session.runID = nil session.codexEventTask?.cancel() session.codexEventTask = nil @@ -8278,11 +8285,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { if let controller = session.codexController { await controller.shutdown() } - session.codexController = nil - session.codexControllerPermissionProfile = nil - session.codexControllerTaskLabelKind = nil - session.codexControllerWorkspacePath = nil - session.codexControllerFeatureState = nil + clearCodexControllerInstanceState(for: session) session.runID = nil session.codexEventTask?.cancel() session.codexEventTask = nil diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexRuntimeWorkspacePaths.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexRuntimeWorkspacePaths.swift new file mode 100644 index 000000000..72018d46f --- /dev/null +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexRuntimeWorkspacePaths.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Launch/execution directory pair for one Codex controller. +/// +/// A worktree-bound session launches the Codex app-server from the workspace's logical root so +/// process startup behaves exactly like an unbound session, while thread and turn execution still +/// target the bound worktree. Unbound sessions use the same directory for both roles. +struct CodexRuntimeWorkspacePaths: Equatable { + /// Directory the Codex app-server process launches from. + let processLaunchDirectory: String? + /// Directory Codex thread/turn execution and the primary sandbox writable root target. + let executionDirectory: String? + + private init(processLaunchDirectory: String?, executionDirectory: String?) { + self.processLaunchDirectory = processLaunchDirectory + self.executionDirectory = executionDirectory + } + + /// Both roles share one directory — the shape of every session without a primary + /// worktree binding. + static func uniform(_ path: String?) -> CodexRuntimeWorkspacePaths { + CodexRuntimeWorkspacePaths(processLaunchDirectory: path, executionDirectory: path) + } + + /// A validated worktree binding keeps process launch anchored to its logical workspace while + /// routing thread and turn execution through the worktree. + static func worktreeBound( + logicalRootPath: String, + validatedWorktreeRootPath: String + ) -> CodexRuntimeWorkspacePaths { + CodexRuntimeWorkspacePaths( + processLaunchDirectory: logicalRootPath, + executionDirectory: validatedWorktreeRootPath + ) + } +} + +/// Validation failures for the Codex launch-directory projection. +/// +/// Thrown before provider startup so a broken selected logical root is as actionable as an +/// unavailable worktree (`AgentWorktreeRuntimeWorkspaceError`). +enum CodexRuntimeWorkspacePathsError: LocalizedError, Equatable { + case emptyLogicalRoot + case launchDirectoryUnavailable(path: String) + + var errorDescription: String? { + switch self { + case .emptyLogicalRoot: + "Agent session is bound to a worktree whose workspace root path is empty. Rebind the session to a worktree of a loaded workspace root, or unbind the session before starting the agent." + case let .launchDirectoryUnavailable(path): + "Agent session is bound to a worktree for workspace root '\(path)', but that root directory is unavailable. Restore the workspace root, bind the session to another worktree, or unbind the session before starting the agent." + } + } +} diff --git a/Sources/RepoPrompt/Features/AgentMode/Services/AgentWorktreeRuntimeWorkspaceResolver.swift b/Sources/RepoPrompt/Features/AgentMode/Services/AgentWorktreeRuntimeWorkspaceResolver.swift index 42f8b05c2..d1b15c12f 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Services/AgentWorktreeRuntimeWorkspaceResolver.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Services/AgentWorktreeRuntimeWorkspaceResolver.swift @@ -26,32 +26,61 @@ enum AgentWorktreeRuntimeWorkspaceResolver { guard let binding else { return primaryWorkspacePath } - let worktreePath = standardizedWorkspacePath(binding.worktreeRootPath) - guard let worktreePath else { - throw AgentWorktreeRuntimeWorkspaceError(binding: binding) + return try validatedWorktreeRootPath(for: binding) + } + + /// Codex-specific projection of the same primary-binding selection used by + /// `effectiveWorkspacePath`: the app-server process launches from the binding's logical root + /// while thread/turn execution targets the bound worktree. The logical root is validated + /// eagerly so a launch-directory failure surfaces before provider startup instead of as an + /// opaque process-spawn error. + static func codexRuntimeWorkspacePaths( + bindings: [AgentSessionWorktreeBinding], + fallbackWorkspacePath: String? + ) throws -> CodexRuntimeWorkspacePaths { + let primaryWorkspacePath = standardizedWorkspacePath(fallbackWorkspacePath) + let binding = primaryExecutionBinding( + in: bindings, + fallbackWorkspacePath: fallbackWorkspacePath + ) + + guard let binding else { + return .uniform(primaryWorkspacePath) } - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: worktreePath, isDirectory: &isDirectory), - isDirectory.boolValue - else { - throw AgentWorktreeRuntimeWorkspaceError(binding: binding) + let executionDirectory = try validatedWorktreeRootPath(for: binding) + guard let processLaunchDirectory = standardizedWorkspacePath(binding.logicalRootPath) else { + throw CodexRuntimeWorkspacePathsError.emptyLogicalRoot } - return worktreePath + guard directoryExists(atPath: processLaunchDirectory) else { + throw CodexRuntimeWorkspacePathsError.launchDirectoryUnavailable(path: processLaunchDirectory) + } + return .worktreeBound( + logicalRootPath: processLaunchDirectory, + validatedWorktreeRootPath: executionDirectory + ) } static func validateBindingsAvailable(_ bindings: [AgentSessionWorktreeBinding]) throws { for binding in bindings { - let worktreePath = standardizedWorkspacePath(binding.worktreeRootPath) - guard let worktreePath else { - throw AgentWorktreeRuntimeWorkspaceError(binding: binding) - } - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: worktreePath, isDirectory: &isDirectory), - isDirectory.boolValue - else { - throw AgentWorktreeRuntimeWorkspaceError(binding: binding) - } + _ = try validatedWorktreeRootPath(for: binding) + } + } + + private static func validatedWorktreeRootPath( + for binding: AgentSessionWorktreeBinding + ) throws -> String { + guard let worktreePath = standardizedWorkspacePath(binding.worktreeRootPath), + directoryExists(atPath: worktreePath) + else { + throw AgentWorktreeRuntimeWorkspaceError(binding: binding) } + return worktreePath + } + + private static func directoryExists(atPath path: String) -> Bool { + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + && isDirectory.boolValue } static func standardizedWorkspacePath(_ path: String?) -> String? { diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+TabSession.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+TabSession.swift index e6dddc5c8..61913e99f 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+TabSession.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+TabSession.swift @@ -495,9 +495,10 @@ extension AgentModeViewModel { /// The task label kind the current Codex controller was created with. /// Used to detect when role-specific native tool overrides require controller recycling. var codexControllerTaskLabelKind: AgentModelCatalog.TaskLabelKind? - /// The effective workspace path the current Codex controller was created with. - /// Used to recycle the provider when a session worktree binding changes cwd. - var codexControllerWorkspacePath: String? + /// The launch/execution directory pair the current Codex controller was created with. + /// Controller replacement key: the provider is recycled when either directory changes, + /// e.g. when a session worktree binding moves the execution cwd. + var codexControllerWorkspacePaths: CodexRuntimeWorkspacePaths? struct CodexControllerFeatureState: Equatable { var computerUseEnabled: Bool var goalSupportEnabled: Bool diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel.swift index e84107e5f..13bd17dd3 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel.swift @@ -42,7 +42,7 @@ final class AgentModeViewModel: ObservableObject { _ runID: UUID, _ tabID: UUID, _ windowID: Int, - _ workspacePath: String?, + _ workspacePaths: CodexRuntimeWorkspacePaths, _ permissionProfile: AgentPermissionProfile, _ taskLabelKind: AgentModelCatalog.TaskLabelKind? ) -> any CodexSessionControlling @@ -1419,6 +1419,12 @@ final class AgentModeViewModel: ObservableObject { fallbackWorkspacePath: codexWorkspacePathProvider() ) } + let codexRuntimeWorkspacePathsProvider: (TabSession) throws -> CodexRuntimeWorkspacePaths = { session in + try Self.codexRuntimeWorkspacePaths( + for: session, + fallbackWorkspacePath: codexWorkspacePathProvider() + ) + } workspacePathProvider = codexWorkspacePathProvider attachmentWorkspaceDirectoryProvider = { [weak workspaceManager] in guard let workspaceManager, workspaceManager.activeWorkspace != nil else { @@ -1426,7 +1432,7 @@ final class AgentModeViewModel: ObservableObject { } return FileManager.default.temporaryDirectory } - let codexControllerFactory: CodexAgentModeCoordinator.CodexControllerFactory = { runID, tabID, windowID, workspacePath, permissionProfile, _, computerUseEnabled in + let codexControllerFactory: CodexAgentModeCoordinator.CodexControllerFactory = { runID, tabID, windowID, workspacePaths, permissionProfile, _, computerUseEnabled in let client = CodexAppServerClient() let options = CodexNativeSessionController.Options.agentModeDefault( forceExperimentalSteering: true, @@ -1444,7 +1450,7 @@ final class AgentModeViewModel: ObservableObject { runID: runID, tabID: tabID, windowID: windowID, - workspacePath: workspacePath, + workspacePaths: workspacePaths, options: options, clientShutdownBehavior: .stopOnShutdown, expectedMCPClientName: AgentProviderKind.codexExec.mcpClientNameHint @@ -1492,7 +1498,7 @@ final class AgentModeViewModel: ObservableObject { usesProductionAgentDefaultsAndModelPolling = true codexCoordinator = CodexAgentModeCoordinator( windowID: windowID, - workspacePathProvider: sessionWorkspacePathProvider, + runtimeWorkspacePathsProvider: codexRuntimeWorkspacePathsProvider, codexControllerFactory: codexControllerFactory, connectionPolicyInstaller: connectionPolicyInstaller, shouldManageCodexTooling: true, @@ -1647,10 +1653,16 @@ final class AgentModeViewModel: ObservableObject { fallbackWorkspacePath: codexWorkspacePathProvider() ) } + let codexRuntimeWorkspacePathsProvider: (TabSession) throws -> CodexRuntimeWorkspacePaths = { session in + try Self.codexRuntimeWorkspacePaths( + for: session, + fallbackWorkspacePath: codexWorkspacePathProvider() + ) + } workspacePathProvider = codexWorkspacePathProvider let codexControllerFactory: CodexAgentModeCoordinator.CodexControllerFactory = codexControllerFactoryWithComputerUse - ?? { runID, tabID, windowID, workspacePath, permissionProfile, taskLabelKind, _ in - codexControllerFactory(runID, tabID, windowID, workspacePath, permissionProfile, taskLabelKind) + ?? { runID, tabID, windowID, workspacePaths, permissionProfile, taskLabelKind, _ in + codexControllerFactory(runID, tabID, windowID, workspacePaths, permissionProfile, taskLabelKind) } self.headlessProviderFactory = headlessProviderFactory self.acpProviderFactory = acpProviderFactory @@ -1675,7 +1687,7 @@ final class AgentModeViewModel: ObservableObject { } codexCoordinator = CodexAgentModeCoordinator( windowID: testWindowID, - workspacePathProvider: sessionWorkspacePathProvider, + runtimeWorkspacePathsProvider: codexRuntimeWorkspacePathsProvider, codexControllerFactory: codexControllerFactory, connectionPolicyInstaller: connectionPolicyInstaller, shouldManageCodexTooling: shouldManageCodexTooling, @@ -1844,6 +1856,13 @@ final class AgentModeViewModel: ObservableObject { ) } + func codexRuntimeWorkspacePaths(for session: TabSession) throws -> CodexRuntimeWorkspacePaths { + try Self.codexRuntimeWorkspacePaths( + for: session, + fallbackWorkspacePath: workspacePathProvider() + ) + } + func primaryExecutionBinding(for session: TabSession) -> AgentSessionWorktreeBinding? { Self.primaryExecutionBinding(in: session.worktreeBindings, fallbackWorkspacePath: workspacePathProvider()) } @@ -1879,6 +1898,16 @@ final class AgentModeViewModel: ObservableObject { ) } + private static func codexRuntimeWorkspacePaths( + for session: TabSession, + fallbackWorkspacePath: String? + ) throws -> CodexRuntimeWorkspacePaths { + try AgentWorktreeRuntimeWorkspaceResolver.codexRuntimeWorkspacePaths( + bindings: session.worktreeBindings, + fallbackWorkspacePath: fallbackWorkspacePath + ) + } + private static func standardizedWorkspacePath(_ path: String?) -> String? { AgentWorktreeRuntimeWorkspaceResolver.standardizedWorkspacePath(path) } diff --git a/Sources/RepoPrompt/Features/Diagnostics/AgentMode/Stress/AgentChatStressHarness.swift b/Sources/RepoPrompt/Features/Diagnostics/AgentMode/Stress/AgentChatStressHarness.swift index c26b2e9c0..d28d78508 100644 --- a/Sources/RepoPrompt/Features/Diagnostics/AgentMode/Stress/AgentChatStressHarness.swift +++ b/Sources/RepoPrompt/Features/Diagnostics/AgentMode/Stress/AgentChatStressHarness.swift @@ -1425,7 +1425,7 @@ runID: UUID(), tabID: UUID(), windowID: windowID, - workspacePath: configuration.workspaceRootPaths.first + workspacePaths: .uniform(configuration.workspaceRootPaths.first) ) let recorder = PersistedCodexFixtureEventRecorder() let eventTask = Task { diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift index 6ceac669d..5d3f00dd4 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift @@ -133,21 +133,22 @@ actor CodexAppServerClient { let additionalPathHints: [String] let enableDebugLogging: Bool let requestTimeout: TimeInterval? - /// Working directory for the Codex app-server process. + /// Launch directory for the Codex app-server process. Distinct from the thread/turn + /// execution cwd, which the controller sends per request. /// When nil, falls back to temp directory via CLIProcessConfiguration default. - let workingDirectory: String? - let processFeaturePolicy: CodexOverrides.FeaturePolicy + private(set) var processLaunchDirectory: String? + private(set) var processFeaturePolicy: CodexOverrides.FeaturePolicy /// Process-level `model_reasoning_summary` override for app-server launch. /// Nil preserves Codex CLI process defaults; pass a value only for an intentional process override. /// Agent Mode should prefer per-thread config instead of process launch config. - let processModelReasoningSummary: CodexOverrides.ReasoningSummary? + private(set) var processModelReasoningSummary: CodexOverrides.ReasoningSummary? init( commandName: String = CLILaunchProfiles.codex.commandName, additionalPathHints: [String] = CLILaunchProfiles.codex.supplementalSearchPaths, enableDebugLogging: Bool = false, requestTimeout: TimeInterval? = nil, - workingDirectory: String? = nil, + processLaunchDirectory: String? = nil, processFeaturePolicy: CodexOverrides.FeaturePolicy = .defaultDisabled, processModelReasoningSummary: CodexOverrides.ReasoningSummary? = nil ) { @@ -155,10 +156,22 @@ actor CodexAppServerClient { self.additionalPathHints = additionalPathHints self.enableDebugLogging = enableDebugLogging self.requestTimeout = requestTimeout - self.workingDirectory = workingDirectory + self.processLaunchDirectory = processLaunchDirectory self.processFeaturePolicy = processFeaturePolicy self.processModelReasoningSummary = processModelReasoningSummary } + + mutating func replaceProcessLaunchDirectory(_ path: String?) { + processLaunchDirectory = path + } + + mutating func replaceProcessLaunchPolicy( + featurePolicy: CodexOverrides.FeaturePolicy, + modelReasoningSummary: CodexOverrides.ReasoningSummary? + ) { + processFeaturePolicy = featurePolicy + processModelReasoningSummary = modelReasoningSummary + } } struct Notification { @@ -398,20 +411,12 @@ actor CodexAppServerClient { return registered } - /// Updates the working directory for the next process start. + /// Updates the process launch directory for the next process start. /// Must be called before `startIfNeeded()` to take effect. - func updateWorkingDirectory(_ path: String?) { + func updateProcessLaunchDirectory(_ path: String?) { let trimmed = path?.trimmingCharacters(in: .whitespacesAndNewlines) let normalized = (trimmed?.isEmpty == false) ? trimmed : nil - config = Config( - commandName: config.commandName, - additionalPathHints: config.additionalPathHints, - enableDebugLogging: config.enableDebugLogging, - requestTimeout: config.requestTimeout, - workingDirectory: normalized, - processFeaturePolicy: config.processFeaturePolicy, - processModelReasoningSummary: config.processModelReasoningSummary - ) + config.replaceProcessLaunchDirectory(normalized) } func updateProcessFeaturePolicy(_ featurePolicy: CodexOverrides.FeaturePolicy) async { @@ -428,14 +433,9 @@ actor CodexAppServerClient { guard featurePolicy != config.processFeaturePolicy || modelReasoningSummary != config.processModelReasoningSummary else { return } - config = Config( - commandName: config.commandName, - additionalPathHints: config.additionalPathHints, - enableDebugLogging: config.enableDebugLogging, - requestTimeout: config.requestTimeout, - workingDirectory: config.workingDirectory, - processFeaturePolicy: featurePolicy, - processModelReasoningSummary: modelReasoningSummary + config.replaceProcessLaunchPolicy( + featurePolicy: featurePolicy, + modelReasoningSummary: modelReasoningSummary ) if process != nil { await terminateTransport(flushStdout: true, reason: .explicitStop) @@ -955,7 +955,7 @@ actor CodexAppServerClient { command: resolution.resolvedCommand, arguments: args, environment: environment, - workingDirectory: config.workingDirectory + workingDirectory: config.processLaunchDirectory ) stdoutFramer = LineFramer() stdoutTail.removeAll(keepingCapacity: false) diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift index 788cce6f9..dd0171ba4 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift @@ -575,7 +575,10 @@ final class CodexNativeSessionController { private let runID: UUID private let tabID: UUID private let windowID: Int - private let workspacePath: String? + /// App-server process launch directory (the workspace logical root when worktree-bound). + private let processLaunchDirectory: String? + /// Thread/turn execution directory (the bound worktree when one is selected). + private let executionDirectory: String? private let options: Options private let clientShutdownBehavior: ClientShutdownBehavior private let expectedMCPClientName: String? @@ -742,7 +745,7 @@ final class CodexNativeSessionController { runID: UUID, tabID: UUID, windowID: Int, - workspacePath: String?, + workspacePaths: CodexRuntimeWorkspacePaths, forceExperimentalSteering: Bool = false, options: Options? = nil, clientShutdownBehavior: ClientShutdownBehavior = .none, @@ -753,7 +756,8 @@ final class CodexNativeSessionController { self.runID = runID self.tabID = tabID self.windowID = windowID - self.workspacePath = workspacePath + processLaunchDirectory = workspacePaths.processLaunchDirectory + executionDirectory = workspacePaths.executionDirectory self.options = options ?? Self.Options.agentModeDefault(forceExperimentalSteering: forceExperimentalSteering) self.clientShutdownBehavior = clientShutdownBehavior self.expectedMCPClientName = expectedMCPClientName @@ -903,7 +907,7 @@ final class CodexNativeSessionController { } private static func makeRawEventLogFileURL( - workspacePath: String?, + executionDirectory: String?, threadID: String ) -> URL? { let defaults = UserDefaults.standard @@ -914,10 +918,10 @@ final class CodexNativeSessionController { let expanded = NSString(string: overridePath).expandingTildeInPath return URL(fileURLWithPath: expanded, isDirectory: true) } - if let workspacePath = workspacePath?.trimmingCharacters(in: .whitespacesAndNewlines), - !workspacePath.isEmpty + if let executionDirectory = executionDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), + !executionDirectory.isEmpty { - return URL(fileURLWithPath: workspacePath, isDirectory: true) + return URL(fileURLWithPath: executionDirectory, isDirectory: true) .appendingPathComponent(".codexlogs", isDirectory: true) } return MCPFilesystemConstants.identity.temporaryRootURL() @@ -944,7 +948,7 @@ final class CodexNativeSessionController { return } guard let fileURL = Self.makeRawEventLogFileURL( - workspacePath: workspacePath, + executionDirectory: executionDirectory, threadID: threadIdentifier ) else { return @@ -998,7 +1002,7 @@ final class CodexNativeSessionController { "runID": runID.uuidString, "tabID": tabID.uuidString, "threadID": threadID ?? "", - "workspacePath": workspacePath ?? "" + "workspacePath": executionDirectory ?? "" ]) } var record: [String: Any] = [ @@ -1080,7 +1084,7 @@ final class CodexNativeSessionController { .init(clientName: expectedMCPClientName, runID: runID) ) } - await client.updateWorkingDirectory(workspacePath) + await client.updateProcessLaunchDirectory(processLaunchDirectory) await updateClientProcessLaunchPolicy() // Re-check: the pre-launch setup above has suspension points after the first check. try Task.checkCancellation() @@ -1103,8 +1107,8 @@ final class CodexNativeSessionController { params["effort"] = reasoningEffort } Self.addServiceTier(serviceTier, to: ¶ms) - if let workspacePath { - params["cwd"] = workspacePath + if let executionDirectory { + params["cwd"] = executionDirectory } if !configOverrides.isEmpty { params["config"] = configOverrides @@ -1132,8 +1136,8 @@ final class CodexNativeSessionController { params["effort"] = reasoningEffort } Self.addServiceTier(serviceTier, to: ¶ms) - if let workspacePath { - params["cwd"] = workspacePath + if let executionDirectory { + params["cwd"] = executionDirectory } if !configOverrides.isEmpty { params["config"] = configOverrides @@ -1390,8 +1394,8 @@ final class CodexNativeSessionController { params["effort"] = reasoningEffort } Self.addServiceTier(serviceTier, to: ¶ms) - if let workspacePath { - params["cwd"] = workspacePath + if let executionDirectory { + params["cwd"] = executionDirectory } #if DEBUG print("[CodexNativeSessionController] turn/start request model=\(String(describing: params["model"] ?? "default")) effort=\(String(describing: params["effort"] ?? "default")) serviceTier=\(String(describing: params["serviceTier"] ?? "missing")) threadID=\(threadID)") @@ -1407,7 +1411,7 @@ final class CodexNativeSessionController { requestParams["approvalsReviewer"] = options.approvalReviewerProvider().appServerRequestValue requestParams["sandboxPolicy"] = Self.appServerTurnSandboxPolicyPayload( mode: sandboxMode, - workspacePath: workspacePath + executionDirectory: executionDirectory ) // app-server v2 turn/start does not accept a config override bag. // Thread-level config changes take effect on thread/start or thread/resume. @@ -3447,7 +3451,7 @@ final class CodexNativeSessionController { runID: UUID(), tabID: UUID(), windowID: 1, - workspacePath: nil + workspacePaths: .uniform(nil) ) return controller.parseExecCommandEndEvent(params: params)?.resultJSON } @@ -7963,7 +7967,7 @@ final class CodexNativeSessionController { static func appServerTurnSandboxPolicyPayload( mode: CodexAgentToolPreferences.SandboxMode, - workspacePath: String? + executionDirectory: String? ) -> [String: Any] { switch mode { case .readOnly: @@ -7975,10 +7979,10 @@ final class CodexNativeSessionController { "type": "workspaceWrite", "networkAccess": true ] - if let workspacePath = workspacePath?.trimmingCharacters(in: .whitespacesAndNewlines), - !workspacePath.isEmpty + if let executionDirectory = executionDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), + !executionDirectory.isEmpty { - payload["writableRoots"] = [workspacePath] + payload["writableRoots"] = [executionDirectory] } return payload } diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/CodexCLIProvider.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/CodexCLIProvider.swift index 87c52cf51..752fe4a5d 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/CodexCLIProvider.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/CodexCLIProvider.swift @@ -773,7 +773,7 @@ final class CodexCLIProvider: AIProvider { runID: UUID(), tabID: UUID(), windowID: 0, - workspacePath: workingDirectory, + workspacePaths: .uniform(workingDirectory), options: options, // The transport is owned by the outer request lifecycle, not by the // single-turn controller. diff --git a/Tests/RepoPromptTests/AI/CodexNativeSessionControllerTurnDispatchTests.swift b/Tests/RepoPromptTests/AI/CodexNativeSessionControllerTurnDispatchTests.swift index d33ffa41a..bc884551c 100644 --- a/Tests/RepoPromptTests/AI/CodexNativeSessionControllerTurnDispatchTests.swift +++ b/Tests/RepoPromptTests/AI/CodexNativeSessionControllerTurnDispatchTests.swift @@ -316,7 +316,7 @@ final class CodexNativeSessionControllerTurnDispatchTests: XCTestCase { runID: UUID(), tabID: UUID(), windowID: 1, - workspacePath: "/tmp/workspace", + workspacePaths: .uniform("/tmp/workspace"), requestExecutor: { method, params, timeout in try recorder.handle(method: method, params: params, timeout: timeout) } diff --git a/Tests/RepoPromptTests/AgentMode/Codex/CodexFallbackFIFOTests.swift b/Tests/RepoPromptTests/AgentMode/Codex/CodexFallbackFIFOTests.swift index 01df17a19..f46b6595b 100644 --- a/Tests/RepoPromptTests/AgentMode/Codex/CodexFallbackFIFOTests.swift +++ b/Tests/RepoPromptTests/AgentMode/Codex/CodexFallbackFIFOTests.swift @@ -941,7 +941,7 @@ final class CodexFallbackFIFOTests: XCTestCase { runID: UUID(), tabID: UUID(), windowID: 1, - workspacePath: "/tmp/workspace", + workspacePaths: .uniform("/tmp/workspace"), requestExecutor: { method, params, timeout in try recorder.handle(method: method, params: params, timeout: timeout) } diff --git a/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerEventRecoveryTests.swift b/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerEventRecoveryTests.swift index b2a289024..c18134196 100644 --- a/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerEventRecoveryTests.swift +++ b/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerEventRecoveryTests.swift @@ -962,7 +962,7 @@ final class CodexNativeSessionControllerEventRecoveryTests: XCTestCase { runID: UUID(), tabID: UUID(), windowID: 1, - workspacePath: nil + workspacePaths: .uniform(nil) ) let started = try XCTUnwrap(controller.test_parseToolLifecycleEvent( @@ -1592,7 +1592,7 @@ final class CodexNativeSessionControllerEventRecoveryTests: XCTestCase { runID: UUID(), tabID: UUID(), windowID: 1, - workspacePath: nil + workspacePaths: .uniform(nil) ) } diff --git a/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift b/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift index ee8ccc59c..f50fc6e70 100644 --- a/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift +++ b/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift @@ -96,7 +96,7 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { commandName: executableURL.path, additionalPathHints: [], requestTimeout: 5, - workingDirectory: directory.path + processLaunchDirectory: directory.path ) ) @@ -122,7 +122,7 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { commandName: executableURL.path, additionalPathHints: [], requestTimeout: 5, - workingDirectory: directory.path, + processLaunchDirectory: directory.path, processModelReasoningSummary: .auto ) ) @@ -135,6 +135,138 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { XCTAssertTrue(arguments.contains("model_reasoning_summary=auto")) } + func testProcessLaunchDirectoryUpdateKeepsRunningTransportAndAppliesAfterRestart() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexNativeSessionControllerGoalConfigTests-\(UUID().uuidString)", isDirectory: true) + let launchDirectoryA = directory.appendingPathComponent("launch-a", isDirectory: true) + let launchDirectoryB = directory.appendingPathComponent("launch-b", isDirectory: true) + try FileManager.default.createDirectory(at: launchDirectoryA, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: launchDirectoryB, withIntermediateDirectories: true) + temporaryDirectories.append(directory) + + let recordURL = directory.appendingPathComponent("requests.jsonl") + let executableURL = try makeFakeCodexAppServer(in: directory, recordURL: recordURL) + let client = CodexAppServerClient() + await client.updateConfig( + CodexAppServerClient.Config( + commandName: executableURL.path, + additionalPathHints: [], + requestTimeout: 5, + processLaunchDirectory: launchDirectoryA.path + ) + ) + + try await client.startIfNeeded() + let initialProcessIDValue = await client.debugProcessID() + let initialProcessID = try XCTUnwrap(initialProcessIDValue) + let initialGeneration = await client.debugTransportGeneration() + + await client.updateProcessLaunchDirectory(launchDirectoryB.path) + + let updatedProcessID = await client.debugProcessID() + let updatedGeneration = await client.debugTransportGeneration() + let isRunningAfterDirectoryUpdate = await client.debugIsProcessRunning() + XCTAssertEqual(updatedProcessID, initialProcessID) + XCTAssertEqual(updatedGeneration, initialGeneration) + XCTAssertTrue(isRunningAfterDirectoryUpdate) + XCTAssertEqual( + try recordedRequests(for: "__process_args", at: recordURL) + .compactMap { $0["cwd"] as? String } + .map { URL(fileURLWithPath: $0).resolvingSymlinksInPath().path }, + [launchDirectoryA.resolvingSymlinksInPath().path] + ) + + await client.stop() + try await client.startIfNeeded() + + let restartedGeneration = await client.debugTransportGeneration() + let isRunningAfterRestart = await client.debugIsProcessRunning() + XCTAssertEqual(restartedGeneration, initialGeneration + 1) + XCTAssertTrue(isRunningAfterRestart) + XCTAssertEqual( + try recordedRequests(for: "__process_args", at: recordURL) + .compactMap { $0["cwd"] as? String } + .map { URL(fileURLWithPath: $0).resolvingSymlinksInPath().path }, + [ + launchDirectoryA.resolvingSymlinksInPath().path, + launchDirectoryB.resolvingSymlinksInPath().path + ] + ) + await client.stop() + } + + func testProcessLaunchPolicyUpdateRestartsOnlyForEffectiveChanges() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexNativeSessionControllerGoalConfigTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + temporaryDirectories.append(directory) + + let recordURL = directory.appendingPathComponent("requests.jsonl") + let executableURL = try makeFakeCodexAppServer(in: directory, recordURL: recordURL) + let client = CodexAppServerClient() + await client.updateConfig( + CodexAppServerClient.Config( + commandName: executableURL.path, + additionalPathHints: [], + requestTimeout: 5, + processLaunchDirectory: directory.path + ) + ) + + try await client.startIfNeeded() + let initialProcessIDValue = await client.debugProcessID() + let initialProcessID = try XCTUnwrap(initialProcessIDValue) + let initialGeneration = await client.debugTransportGeneration() + + await client.updateProcessLaunchPolicy( + featurePolicy: .defaultDisabled, + modelReasoningSummary: nil + ) + + let unchangedProcessID = await client.debugProcessID() + let unchangedGeneration = await client.debugTransportGeneration() + let isRunningAfterUnchangedPolicy = await client.debugIsProcessRunning() + let unchangedTerminationReason = await client.debugLastTransportTerminationReason() + XCTAssertEqual(unchangedProcessID, initialProcessID) + XCTAssertEqual(unchangedGeneration, initialGeneration) + XCTAssertTrue(isRunningAfterUnchangedPolicy) + XCTAssertNil(unchangedTerminationReason) + + await client.updateProcessLaunchPolicy( + featurePolicy: .enabledForGoals, + modelReasoningSummary: nil + ) + + let isRunningAfterFeaturePolicyChange = await client.debugIsProcessRunning() + let processIDAfterFeaturePolicyChange = await client.debugProcessID() + let generationAfterFeaturePolicyChange = await client.debugTransportGeneration() + let featurePolicyTerminationReason = await client.debugLastTransportTerminationReason() + XCTAssertFalse(isRunningAfterFeaturePolicyChange) + XCTAssertNil(processIDAfterFeaturePolicyChange) + XCTAssertEqual(generationAfterFeaturePolicyChange, initialGeneration) + XCTAssertEqual(featurePolicyTerminationReason, .explicitStop) + + try await client.startIfNeeded() + let restartedGeneration = await client.debugTransportGeneration() + let isRunningAfterPolicyRestart = await client.debugIsProcessRunning() + XCTAssertEqual(restartedGeneration, initialGeneration + 1) + XCTAssertTrue(isRunningAfterPolicyRestart) + + await client.updateProcessLaunchPolicy( + featurePolicy: .enabledForGoals, + modelReasoningSummary: .detailed + ) + + let isRunningAfterReasoningSummaryChange = await client.debugIsProcessRunning() + let processIDAfterReasoningSummaryChange = await client.debugProcessID() + let generationAfterReasoningSummaryChange = await client.debugTransportGeneration() + let reasoningSummaryTerminationReason = await client.debugLastTransportTerminationReason() + XCTAssertFalse(isRunningAfterReasoningSummaryChange) + XCTAssertNil(processIDAfterReasoningSummaryChange) + XCTAssertEqual(generationAfterReasoningSummaryChange, restartedGeneration) + XCTAssertEqual(reasoningSummaryTerminationReason, .explicitStop) + } + func testNativeSessionControllerDefaultOptionsOmitProcessReasoningSummaryOverride() async throws { let options = CodexNativeSessionController.Options( requestTimeout: 5, @@ -380,7 +512,7 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { commandName: executableURL.path, additionalPathHints: [], requestTimeout: 5, - workingDirectory: directory.path + processLaunchDirectory: directory.path ) ) @@ -389,7 +521,7 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { runID: UUID(), tabID: UUID(), windowID: 0, - workspacePath: directory.path, + workspacePaths: .uniform(directory.path), options: options, clientShutdownBehavior: .stopOnShutdown ) @@ -405,13 +537,14 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { let script = """ #!/usr/bin/env python3 import json + import os import sys record_path = \(String(reflecting: recordURL.path)) ignore_memory_mode_requests = \(ignoreMemoryModeRequests ? "True" : "False") with open(record_path, "a", encoding="utf-8") as handle: - handle.write(json.dumps({"method": "__process_args", "argv": sys.argv[1:]}) + "\\n") + handle.write(json.dumps({"method": "__process_args", "argv": sys.argv[1:], "cwd": os.getcwd()}) + "\\n") def respond(request_id, result): print(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}), flush=True) diff --git a/Tests/RepoPromptTests/Helpers/CodexSessionControllerTestDefaults.swift b/Tests/RepoPromptTests/Helpers/CodexSessionControllerTestDefaults.swift index ddb077f5e..a07749bdc 100644 --- a/Tests/RepoPromptTests/Helpers/CodexSessionControllerTestDefaults.swift +++ b/Tests/RepoPromptTests/Helpers/CodexSessionControllerTestDefaults.swift @@ -1,3 +1,4 @@ +import Foundation @testable import RepoPromptApp protocol CodexSessionControllerTurnDispatchTestDefaults: CodexSessionControlling {} @@ -25,3 +26,94 @@ extension CodexSessionControllerTurnDispatchTestDefaults { CodexTurnInterruptReceipt(interruptedTurnID: expectedTurnID) } } + +protocol CodexSessionControllerPassiveStubDefaults: CodexSessionControllerTurnDispatchTestDefaults {} + +extension CodexSessionControllerPassiveStubDefaults { + var hasActiveThread: Bool { + false + } + + func ensureEventsStreamReady() {} + + func startOrResume( + existing: CodexNativeSessionController.SessionRef?, + baseInstructions: String + ) async throws -> CodexNativeSessionController.SessionRef { + try await startOrResume( + existing: existing, + baseInstructions: baseInstructions, + model: nil, + reasoningEffort: nil, + serviceTier: nil + ) + } + + func startOrResume( + existing: CodexNativeSessionController.SessionRef?, + baseInstructions: String, + model: String?, + reasoningEffort: String? + ) async throws -> CodexNativeSessionController.SessionRef { + try await startOrResume( + existing: existing, + baseInstructions: baseInstructions, + model: model, + reasoningEffort: reasoningEffort, + serviceTier: nil + ) + } + + func startOrResume( + existing _: CodexNativeSessionController.SessionRef?, + baseInstructions _: String, + model: String?, + reasoningEffort: String?, + serviceTier _: String? + ) async throws -> CodexNativeSessionController.SessionRef { + CodexNativeSessionController.SessionRef( + conversationID: "fake", + rolloutPath: nil, + model: model, + reasoningEffort: reasoningEffort + ) + } + + func readThreadSnapshot( + includeTurns _: Bool, + timeout _: TimeInterval? + ) async throws -> CodexNativeSessionController.ThreadSnapshot { + CodexNativeSessionController.ThreadSnapshot( + conversationID: "fake", + rolloutPath: nil, + model: nil, + reasoningEffort: nil, + runtimeStatus: .idle, + currentTurnID: nil, + activeTurnIDs: [], + latestTurnStatus: nil + ) + } + + func setThreadName(_: String, threadID _: String?) async throws {} + func compactThread() async throws {} + + func getThreadGoal() async throws -> CodexNativeSessionController.ThreadGoal? { + nil + } + + func setThreadGoalObjective(_: String) async throws -> CodexNativeSessionController.ThreadGoal { + throw CancellationError() + } + + func setThreadGoalStatus(_: CodexNativeSessionController.ThreadGoalStatus) async throws -> CodexNativeSessionController.ThreadGoal { + throw CancellationError() + } + + func clearThreadGoal() async throws -> Bool { + false + } + + func cancelCurrentTurn() async {} + func respondToServerRequest(id _: CodexAppServerRequestID, result _: [String: Any]) async {} +} diff --git a/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift b/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift index 1c006f04f..d54af0e13 100644 --- a/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift +++ b/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift @@ -481,6 +481,262 @@ final class AgentRunWorktreeStartTests: AgentRunWorktreeStartGitSeedTestCase { } } + func testCodexRuntimeWorkspacePathsProjectionCoversBoundUnboundSecondaryAndMissingWorktree() throws { + let root = try makeTemporaryDirectory(named: "projection-root") + let worktree = try makeTemporaryDirectory(named: "projection-worktree") + let secondaryRoot = try makeTemporaryDirectory(named: "projection-secondary-root") + let secondaryWorktree = try makeTemporaryDirectory(named: "projection-secondary-worktree") + let missingWorktree = root.appendingPathComponent("missing-worktree") + let viewModel = makeViewModel(workspacePath: root.path) + + let unboundSession = AgentModeViewModel.TabSession(tabID: UUID()) + XCTAssertEqual( + try viewModel.codexRuntimeWorkspacePaths(for: unboundSession), + .uniform(root.path) + ) + + let boundSession = AgentModeViewModel.TabSession(tabID: UUID()) + boundSession.worktreeBindings = [makeBinding(logicalRoot: root.path, worktreeRoot: worktree.path)] + let boundPair = CodexRuntimeWorkspacePaths.worktreeBound( + logicalRootPath: root.path, + validatedWorktreeRootPath: worktree.path + ) + XCTAssertEqual(try viewModel.codexRuntimeWorkspacePaths(for: boundSession), boundPair) + + // Adding a secondary-root binding must not move either directory of the primary pair. + boundSession.worktreeBindings.append( + makeBinding(logicalRoot: secondaryRoot.path, worktreeRoot: secondaryWorktree.path) + ) + XCTAssertEqual(try viewModel.codexRuntimeWorkspacePaths(for: boundSession), boundPair) + + // A secondary-only binding leaves the session on the unbound fallback pair. + let secondaryOnlySession = AgentModeViewModel.TabSession(tabID: UUID()) + secondaryOnlySession.worktreeBindings = [ + makeBinding(logicalRoot: secondaryRoot.path, worktreeRoot: secondaryWorktree.path) + ] + XCTAssertEqual( + try viewModel.codexRuntimeWorkspacePaths(for: secondaryOnlySession), + .uniform(root.path) + ) + + // Unavailable worktrees keep the existing typed worktree failure. + let missingSession = AgentModeViewModel.TabSession(tabID: UUID()) + missingSession.worktreeBindings = [ + makeBinding(logicalRoot: root.path, worktreeRoot: missingWorktree.path, label: "Feature WT") + ] + XCTAssertThrowsError(try viewModel.codexRuntimeWorkspacePaths(for: missingSession)) { error in + XCTAssertTrue(error is AgentWorktreeRuntimeWorkspaceError, String(describing: error)) + } + } + + func testCodexRuntimeWorkspacePathsFailsClosedOnMalformedSelectedLogicalRoot() throws { + let worktree = try makeTemporaryDirectory(named: "logical-root-worktree") + let missingRoot = worktree.appendingPathComponent("missing-root") + + XCTAssertThrowsError( + try AgentWorktreeRuntimeWorkspaceResolver.codexRuntimeWorkspacePaths( + bindings: [makeBinding(logicalRoot: " ", worktreeRoot: worktree.path)], + fallbackWorkspacePath: nil + ) + ) { error in + XCTAssertEqual(error as? CodexRuntimeWorkspacePathsError, .emptyLogicalRoot) + } + + XCTAssertThrowsError( + try AgentWorktreeRuntimeWorkspaceResolver.codexRuntimeWorkspacePaths( + bindings: [makeBinding(logicalRoot: missingRoot.path, worktreeRoot: worktree.path)], + fallbackWorkspacePath: missingRoot.path + ) + ) { error in + XCTAssertEqual( + error as? CodexRuntimeWorkspacePathsError, + .launchDirectoryUnavailable(path: missingRoot.standardizedFileURL.path) + ) + } + + XCTAssertEqual( + try AgentWorktreeRuntimeWorkspaceResolver.codexRuntimeWorkspacePaths( + bindings: [], + fallbackWorkspacePath: nil + ), + .uniform(nil) + ) + } + + func testCodexControllerFailsClosedWhenInstalledLaunchRootBecomesMalformed() async throws { + let root = try makeTemporaryDirectory(named: "teardown-root") + let worktree = try makeTemporaryDirectory(named: "teardown-worktree") + + var installedController: ReplacementIdentityFakeCodexController? + let viewModel = AgentModeViewModel( + testWorkspacePath: nil, + codexControllerFactory: { _, _, _, _, _, _ in + let controller = ReplacementIdentityFakeCodexController() + installedController = controller + return controller + } + ) + viewModel.test_initializeRunService() + let session = viewModel.session(for: UUID()) + session.selectedAgent = .codexExec + session.worktreeBindings = [makeBinding(logicalRoot: root.path, worktreeRoot: worktree.path)] + + await viewModel.test_codexCoordinator.ensureCodexNativeSession(session: session) + let controller = try XCTUnwrap(installedController) + let installedRunID = try XCTUnwrap(session.runID) + XCTAssertNotNil(session.codexController) + XCTAssertNotNil(session.codexEventTask) + + session.worktreeBindings = [makeBinding(logicalRoot: " ", worktreeRoot: worktree.path)] + await viewModel.test_codexCoordinator.ensureCodexNativeSession(session: session) + + XCTAssertEqual(session.runState, .failed) + XCTAssertEqual(session.runID, installedRunID) + XCTAssertFalse(session.codexNeedsReconnect) + XCTAssertNil(session.codexEventTask) + XCTAssertNil(session.codexEventTaskRunID) + XCTAssertNil(session.codexController) + XCTAssertNil(session.codexControllerPermissionProfile) + XCTAssertNil(session.codexControllerTaskLabelKind) + XCTAssertNil(session.codexControllerWorkspacePaths) + XCTAssertNil(session.codexControllerFeatureState) + XCTAssertEqual(controller.shutdownCallCount, 1) + } + + func testCodexControllerReplacementKeysOnExecutionChangeAndSurvivesSecondaryBindingChanges() async throws { + let root = try makeTemporaryDirectory(named: "replacement-root") + let worktreeA = try makeTemporaryDirectory(named: "replacement-worktree-a") + let worktreeB = try makeTemporaryDirectory(named: "replacement-worktree-b") + let secondaryRoot = try makeTemporaryDirectory(named: "replacement-secondary-root") + let secondaryWorktree = try makeTemporaryDirectory(named: "replacement-secondary-worktree") + + var createdPaths: [CodexRuntimeWorkspacePaths] = [] + let viewModel = AgentModeViewModel( + testWorkspacePath: root.path, + codexControllerFactory: { _, _, _, workspacePaths, _, _ in + createdPaths.append(workspacePaths) + return ReplacementIdentityFakeCodexController() + } + ) + viewModel.test_initializeRunService() + let session = viewModel.session(for: UUID()) + session.selectedAgent = .codexExec + session.worktreeBindings = [makeBinding(logicalRoot: root.path, worktreeRoot: worktreeA.path)] + + await viewModel.test_codexCoordinator.ensureCodexNativeSession(session: session) + XCTAssertEqual(createdPaths, [ + CodexRuntimeWorkspacePaths.worktreeBound( + logicalRootPath: root.path, + validatedWorktreeRootPath: worktreeA.path + ) + ]) + let firstControllerIdentity = try XCTUnwrap(controllerIdentity(in: session)) + + // Unchanged pair: the controller instance survives another ensure pass. + await viewModel.test_codexCoordinator.ensureCodexNativeSession(session: session) + XCTAssertEqual(createdPaths.count, 1) + XCTAssertEqual(controllerIdentity(in: session), firstControllerIdentity) + + // Secondary-binding-only change: the unchanged primary pair keeps the controller. + session.worktreeBindings.append( + makeBinding(logicalRoot: secondaryRoot.path, worktreeRoot: secondaryWorktree.path) + ) + await viewModel.test_codexCoordinator.ensureCodexNativeSession(session: session) + XCTAssertEqual(createdPaths.count, 1) + XCTAssertEqual(controllerIdentity(in: session), firstControllerIdentity) + + // Execution-only change (same launch root, new worktree) replaces the controller. + session.worktreeBindings[0] = makeBinding(logicalRoot: root.path, worktreeRoot: worktreeB.path) + await viewModel.test_codexCoordinator.ensureCodexNativeSession(session: session) + XCTAssertEqual(createdPaths.count, 2) + XCTAssertEqual( + createdPaths.last, + CodexRuntimeWorkspacePaths.worktreeBound( + logicalRootPath: root.path, + validatedWorktreeRootPath: worktreeB.path + ) + ) + XCTAssertNotEqual(controllerIdentity(in: session), firstControllerIdentity) + } + + func testCodexControllerReplacementKeysOnLaunchOnlyChange() async throws { + let rootA = try makeTemporaryDirectory(named: "launch-only-root-a") + let rootB = try makeTemporaryDirectory(named: "launch-only-root-b") + let worktree = try makeTemporaryDirectory(named: "launch-only-worktree") + + var createdPaths: [CodexRuntimeWorkspacePaths] = [] + // No fallback workspace: the single binding is the selected primary regardless of its + // logical root, which lets the launch directory move while the execution directory stays. + let viewModel = AgentModeViewModel( + testWorkspacePath: nil, + codexControllerFactory: { _, _, _, workspacePaths, _, _ in + createdPaths.append(workspacePaths) + return ReplacementIdentityFakeCodexController() + } + ) + viewModel.test_initializeRunService() + let session = viewModel.session(for: UUID()) + session.selectedAgent = .codexExec + session.worktreeBindings = [makeBinding(logicalRoot: rootA.path, worktreeRoot: worktree.path)] + + await viewModel.test_codexCoordinator.ensureCodexNativeSession(session: session) + XCTAssertEqual(createdPaths, [ + CodexRuntimeWorkspacePaths.worktreeBound( + logicalRootPath: rootA.path, + validatedWorktreeRootPath: worktree.path + ) + ]) + let firstControllerIdentity = try XCTUnwrap(controllerIdentity(in: session)) + + session.worktreeBindings = [makeBinding(logicalRoot: rootB.path, worktreeRoot: worktree.path)] + await viewModel.test_codexCoordinator.ensureCodexNativeSession(session: session) + XCTAssertEqual(createdPaths.count, 2) + XCTAssertEqual( + createdPaths.last, + CodexRuntimeWorkspacePaths.worktreeBound( + logicalRootPath: rootB.path, + validatedWorktreeRootPath: worktree.path + ) + ) + XCTAssertNotEqual(controllerIdentity(in: session), firstControllerIdentity) + } + + func testWorktreeCodexControllerFakesPreserveDistinctStreamAndShutdownSemantics() async { + let finishedController = WorktreeStartFakeCodexController() + var finishedIterator = finishedController.events.makeAsyncIterator() + let finishedEvent = await finishedIterator.next() + XCTAssertNil(finishedEvent) + + let replacementController = ReplacementIdentityFakeCodexController() + let iteratorStarted = expectation(description: "replacement stream iterator started") + let streamFinishedBeforeShutdown = expectation(description: "replacement stream finished before shutdown") + streamFinishedBeforeShutdown.isInverted = true + let nextEventTask = Task { () -> CodexNativeSessionController.Event? in + var iterator = replacementController.events.makeAsyncIterator() + iteratorStarted.fulfill() + return await iterator.next() + } + let prematureFinishObserver = Task { + _ = await nextEventTask.value + guard !Task.isCancelled else { return } + streamFinishedBeforeShutdown.fulfill() + } + + await fulfillment(of: [iteratorStarted], timeout: 1) + await fulfillment(of: [streamFinishedBeforeShutdown], timeout: 0.05) + prematureFinishObserver.cancel() + + await replacementController.shutdown() + let eventAfterShutdown = await nextEventTask.value + _ = await prematureFinishObserver.value + XCTAssertNil(eventAfterShutdown) + XCTAssertEqual(replacementController.shutdownCallCount, 1) + } + + private func controllerIdentity(in session: AgentModeViewModel.TabSession) -> ObjectIdentifier? { + session.codexController.map { ObjectIdentifier($0) } + } + func testAgentRunSnapshotIncludesWorktreeBindingFields() throws { let root = try makeTemporaryDirectory(named: "root") let worktree = try makeTemporaryDirectory(named: "worktree") @@ -4111,80 +4367,43 @@ private enum CoordinatorPostCreateFailure: Error { case injected } -private final class WorktreeStartFakeCodexController: CodexSessionControllerTurnDispatchTestDefaults { - var hasActiveThread: Bool { - false - } - +private final class WorktreeStartFakeCodexController: CodexSessionControllerPassiveStubDefaults { var events: AsyncStream { AsyncStream { continuation in continuation.finish() } } - func ensureEventsStreamReady() {} - - func startOrResume( - existing: CodexNativeSessionController.SessionRef?, - baseInstructions: String - ) async throws -> CodexNativeSessionController.SessionRef { - CodexNativeSessionController.SessionRef(conversationID: "fake", rolloutPath: nil, model: nil, reasoningEffort: nil) - } - - func startOrResume( - existing: CodexNativeSessionController.SessionRef?, - baseInstructions: String, - model: String?, - reasoningEffort: String? - ) async throws -> CodexNativeSessionController.SessionRef { - CodexNativeSessionController.SessionRef(conversationID: "fake", rolloutPath: nil, model: model, reasoningEffort: reasoningEffort) - } - - func startOrResume( - existing: CodexNativeSessionController.SessionRef?, - baseInstructions: String, - model: String?, - reasoningEffort: String?, - serviceTier: String? - ) async throws -> CodexNativeSessionController.SessionRef { - CodexNativeSessionController.SessionRef(conversationID: "fake", rolloutPath: nil, model: model, reasoningEffort: reasoningEffort) - } - - func readThreadSnapshot( - includeTurns: Bool, - timeout: TimeInterval? - ) async throws -> CodexNativeSessionController.ThreadSnapshot { - CodexNativeSessionController.ThreadSnapshot( - conversationID: "fake", - rolloutPath: nil, - model: nil, - reasoningEffort: nil, - runtimeStatus: .idle, - currentTurnID: nil, - activeTurnIDs: [], - latestTurnStatus: nil - ) - } + func shutdown() async {} +} - func setThreadName(_ name: String, threadID: String?) async throws {} - func compactThread() async throws {} - func getThreadGoal() async throws -> CodexNativeSessionController.ThreadGoal? { - nil +/// Controller-replacement identity tests need a controller whose events stream stays open: +/// a finished stream triggers the coordinator's unexpected-stream-end recovery, which would +/// recycle the controller for reasons unrelated to the workspace-path replacement key. +private final class ReplacementIdentityFakeCodexController: CodexSessionControllerPassiveStubDefaults { + private var eventsContinuation: AsyncStream.Continuation? + private(set) var shutdownCallCount = 0 + private let eventsStream: AsyncStream + + init() { + var continuationRef: AsyncStream.Continuation? + eventsStream = AsyncStream { continuation in + continuationRef = continuation + } + eventsContinuation = continuationRef } - func setThreadGoalObjective(_ objective: String) async throws -> CodexNativeSessionController.ThreadGoal { - throw CancellationError() + deinit { + eventsContinuation?.finish() } - func setThreadGoalStatus(_ status: CodexNativeSessionController.ThreadGoalStatus) async throws -> CodexNativeSessionController.ThreadGoal { - throw CancellationError() + var events: AsyncStream { + eventsStream } - func clearThreadGoal() async throws -> Bool { - false + func shutdown() async { + shutdownCallCount += 1 + eventsContinuation?.finish() + eventsContinuation = nil } - - func cancelCurrentTurn() async {} - func shutdown() async {} - func respondToServerRequest(id: CodexAppServerRequestID, result: [String: Any]) async {} } private actor AgentRunWorktreeStartAsyncGate { From 9d09d326659771332e2ac34500c159d2f2e69f07 Mon Sep 17 00:00:00 2001 From: MP Date: Thu, 16 Jul 2026 07:04:22 +0800 Subject: [PATCH 02/21] fix: harden Codex app-server process lifecycle --- .../Fixtures/test-suite-contract-ledger.tsv | 13 + .../AppServer/CodexAppServerClient.swift | 596 +++++++++++++---- .../AppServer/CodexProcessStderrCapture.swift | 99 +++ .../AI/Providers/Codex/CodexCLIProvider.swift | 2 + .../Process/ChildProcessExitObserver.swift | 128 ++++ .../Process/ProcessTermination.swift | 45 +- .../ProcessTerminationExitStatusTests.swift | 140 ++++ ...CodexAppServerClientProcessExitTests.swift | 623 ++++++++++++++++++ 8 files changed, 1523 insertions(+), 123 deletions(-) create mode 100644 Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexProcessStderrCapture.swift create mode 100644 Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift create mode 100644 Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index 7c6c7b057..c6a3855ec 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -673,7 +673,9 @@ root/RepoPromptTests.CLIProcessRunnerLifecycleTests/testStreamingProcessTerminat root/RepoPromptTests.CLIProcessRunnerLifecycleTests/testTerminateAndReapUsesSpawnedProcessGroupForDirectProviderStyleProcess root Tests/RepoPromptTests/AI/CLIProcessRunnerLifecycleTests.swift RepoPromptTests.CLIProcessRunnerLifecycleTests testTerminateAndReapUsesSpawnedProcessGroupForDirectProviderStyleProcess AI ai.cli_process_runner.terminate_and_reap_uses_spawned_process_group process_group,direct_provider,descendant_reaping subprocess_lifecycle_regression root_swiftpm routine 1 temporary_pid_marker Terminating a directly spawned provider-style shell reaps the same-process-group descendant recorded by the marker file. Direct provider launches could terminate only the parent and leak child processes. subprocess;filesystem;async_wait test_case retain 0 origin/main reconciliation after commits 5c1504f1/dc79e7a4: reviewed direct-provider process-group reap coverage. root/RepoPromptTests.ProcessTerminationExitStatusTests/testBlockingReapChildStatusPreservesExitAndSignalSemantics root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testBlockingReapChildStatusPreservesExitAndSignalSemantics AI ai.process_termination.blocking_reap_status waitpid,blocking_reap,exit_status,signal,reap_callback subprocess_lifecycle_regression root_swiftpm routine 2 GitProcessLifecycleTarget A dedicated off-executor blocking waitpid reaps normal and signaled children with exact status semantics and marks lifecycle state before await resumes. Git completion could poll unnecessarily, diverge from exit decoding, or expose a post-reap lifecycle signaling gap. 0.100000 subprocess;async_wait test_case retain 0 Oracle P1 process-launch follow-up: event-driven sole-reaper and synchronous post-waitpid hook coverage. root/RepoPromptTests.ProcessTerminationExitStatusTests/testBlockingReapChildStatusTreatsECHILDAsOwnershipError root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testBlockingReapChildStatusTreatsECHILDAsOwnershipError AI ai.process_termination.blocking_reap_echild_ownership_error waitpid,blocking_reap,echild,ownership subprocess_lifecycle_regression root_swiftpm routine 1 A second sole-reaper wait for an already reaped child throws childOwnershipLost instead of fabricating exited(0). ECHILD could be misreported as a successful zero exit and conceal competing waitpid ownership. 0.050000 subprocess;async_wait test_case retain 0 GPT-5.6 Sol Ultra P1 follow-up: ECHILD ownership-error coverage. +root/RepoPromptTests.ProcessTerminationExitStatusTests/testChildProcessExitObserverSharesOneDetailedReap root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testChildProcessExitObserverSharesOneDetailedReap AI ai.process_termination.child_exit_observer.shared_sole_reap waitpid,observer,exit_status,echild,sole_reaper subprocess_lifecycle_regression root_swiftpm routine 1 ChildProcessExitObserver Repeated observer waits share exited(19), and a later direct reap reports childOwnershipLost. Multiple lifecycle consumers could perform competing destructive waits or fabricate exit evidence. subprocess;async_wait test_case retain 0 WI-2 sole-reaper observation coverage. root/RepoPromptTests.ProcessTerminationExitStatusTests/testDecodeWaitStatusPreservesExitSignalAndFallbackSemantics root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testDecodeWaitStatusPreservesExitSignalAndFallbackSemantics AI ai.process_termination.detailed_status_decode waitpid,exit_status,signal,fallback deterministic_unit root_swiftpm fast 4 Raw waitpid statuses decode to exited/uncaughtSignal cases whose normalized, terminationStatus, and terminationReason projections all agree. Detailed decoding could diverge from the historical 128+signal normalization or from NSTask terminationStatus parity. 0.000000 test_case retain 0 posix_spawn git launch migration: new behavioral coverage for ProcessLauncher-based GitService spawning. +root/RepoPromptTests.ProcessTerminationExitStatusTests/testObservedTerminationEscalatesBeforeGroupOnlyCleanup root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testObservedTerminationEscalatesBeforeGroupOnlyCleanup AI ai.process_termination.observed_term_kill_group_cleanup sigterm,sigkill,process_group,root_reap,descendant_cleanup subprocess_lifecycle_regression root_swiftpm routine 1 sigterm_ignoring_process_family Observer-aware teardown escalates a TERM-resistant family to KILL, observes the root signal, removes the descendant, and leaves ECHILD for any second reap. Teardown could race a second waitpid, signal a reused root PID, or leak descendants. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 observer-aware teardown coverage. root/RepoPromptTests.ProcessTerminationExitStatusTests/testWaitForTerminationStatusReportsRealChildExitAndSignal root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testWaitForTerminationStatusReportsRealChildExitAndSignal AI ai.process_termination.detailed_status_reap waitpid,exit_status,signal,reaping subprocess_lifecycle_regression root_swiftpm routine 2 Real children reaped through waitForTerminationStatus report exited(3) and uncaughtSignal(SIGKILL) with normalized parity. The detailed wait variant could mis-decode real waitpid statuses or diverge from the normalized API for signal exits. 0.300000 subprocess;async_wait test_case retain 0 posix_spawn git launch migration: new behavioral coverage for ProcessLauncher-based GitService spawning. root/RepoPromptTests.ChatHistoryJSONOnlyTests/testCurrentChatSessionSaveLoadUsesCEWorkspaceRoot root Tests/RepoPromptTests/ChatHistoryJSONOnlyTests.swift RepoPromptTests.ChatHistoryJSONOnlyTests testCurrentChatSessionSaveLoadUsesCEWorkspaceRoot Root unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.001500 unreviewed retain_pending_review 0 initial census source line 5 root/RepoPromptTests.ChatHistoryJSONOnlyTests/testLegacyChatSessionEditPayloadsAreIgnoredOnDecodeAndOmittedOnEncode root Tests/RepoPromptTests/ChatHistoryJSONOnlyTests.swift RepoPromptTests.ChatHistoryJSONOnlyTests testLegacyChatSessionEditPayloadsAreIgnoredOnDecodeAndOmittedOnEncode Root unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 70 @@ -1026,6 +1028,17 @@ root/RepoPromptTests.CodemapWatcherFenceTests/testReadyDemandsReuseInjectedRunti root/RepoPromptTests.CodemapWatcherFenceTests/testUnloadAndReloadFenceOldLifetimeAndDrainCodemapRootState root Tests/RepoPromptTests/WorkspaceContext/CodemapWatcherFenceTests.swift RepoPromptTests.CodemapWatcherFenceTests testUnloadAndReloadFenceOldLifetimeAndDrainCodemapRootState WorkspaceContext/CodeMap codemap.store_seam.root_lifetime_cleanup unload,reload,root_epoch,stale_ticket,drain async_concurrency_lifecycle root_swiftpm routine 5 CodemapStoreFixture,CodeMapArtifactRuntimeProvider Unload detaches the route, revokes and drains engine authority before return, and same-path reload receives a distinct epoch that cannot reuse the old ticket. Old lifetime tasks or tokens could cross an unload/reload ABA boundary and affect the successor root. filesystem,actor,concurrency test_case+fixture_cleanup+explicit_root_unload retain 0 Slice B inert modern codemap store seam; moved from root/RepoPromptTests.WorkspaceFileContextStoreCodemapSeamTests/testUnloadAndReloadFenceOldLifetimeAndDrainCodemapRootState to root/RepoPromptTests.CodemapWatcherFenceTests/testUnloadAndReloadFenceOldLifetimeAndDrainCodemapRootState during codemap seam split root/RepoPromptTests.CodemapWatcherFenceTests/testWatcherModifyDeleteAndGapAwaitPresentationGraphAndEngineFences root Tests/RepoPromptTests/WorkspaceContext/CodemapWatcherFenceTests.swift RepoPromptTests.CodemapWatcherFenceTests testWatcherModifyDeleteAndGapAwaitPresentationGraphAndEngineFences WorkspaceContext/CodeMap codemap.store.fence.watcher modify,delete,watcher_gap,path_local_survival,presentation_revocation,graph_fence async_concurrency_lifecycle root_swiftpm routine 6 CodemapStoreFixture,WorkspaceCodemapSelectionGraphFactory,WorkspaceCodemapFrozenPresentationBundle,WorkspaceCodemapBindingEngineAccounting Watcher modify/delete preserve unrelated demand/presentation/graph authority while revoking affected handles; a watcher gap drains graph, route, and engine authority before return. Late watcher cleanup could publish stale handles or graph shards, or path-local invalidation could unnecessarily revoke unrelated demand authority. filesystem,git_fixture,artifact_store,actor,concurrency store_session+path_fence+graph_worker+root_unload retain 0 Slice 3C watcher lifecycle fencing and unrelated-path survival; moved from root/RepoPromptTests.WorkspaceFileContextStoreCodemapSeamTests/testWatcherModifyDeleteAndGapAwaitPresentationGraphAndEngineFences to root/RepoPromptTests.CodemapWatcherFenceTests/testWatcherModifyDeleteAndGapAwaitPresentationGraphAndEngineFences during codemap seam split root/RepoPromptTests.CodemapWatcherFenceTests/testWatcherRenamePairFencesOnlyOldAndNewPaths root Tests/RepoPromptTests/WorkspaceContext/CodemapWatcherFenceTests.swift RepoPromptTests.CodemapWatcherFenceTests testWatcherRenamePairFencesOnlyOldAndNewPaths WorkspaceContext/CodeMap codemap.store.fence.watcher_rename_pair watcher,rename_pair,removed_added,path_local,presentation,graph,path_generation async_concurrency_lifecycle root_swiftpm routine 5 CodemapStoreFixture,WorkspaceCodemapSelectionGraphFactory,WorkspaceCodemapFrozenPresentationBundle A watcher removed-plus-added rename revokes the old path, advances the new path generation, and preserves unrelated ready handle, presentation, and graph authority. Treating the added half as catalog replacement could revoke the entire root, while missing either path fence could retain stale rename authority. filesystem,git_fixture,artifact_store,actor,concurrency store_session+path_fence+graph_worker+root_unload retain 0 Slice 3C P1 watcher rename-pair path-local fencing repair; moved from root/RepoPromptTests.WorkspaceFileContextStoreCodemapSeamTests/testWatcherRenamePairFencesOnlyOldAndNewPaths to root/RepoPromptTests.CodemapWatcherFenceTests/testWatcherRenamePairFencesOnlyOldAndNewPaths during codemap seam split +root/RepoPromptTests.CodexAppServerClientProcessExitTests/testDecodeRecoveryExhaustionWinsOverObservedTermination root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testDecodeRecoveryExhaustionWinsOverObservedTermination AgentMode/Codex codex.app_server.lifecycle.decode_exhaustion_precedence decode_recovery,poisoning,observer_exit,first_claim subprocess_lifecycle_regression root_swiftpm routine 1 persistent_codex_fixture Exhausting the decode recovery budget terminates the matching generation with its original reason despite the resulting observed child exit. Malformed-output poisoning could be relabeled as an unexpected crash or affect a later generation. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 lifecycle-precedence coverage. +root/RepoPromptTests.CodexAppServerClientProcessExitTests/testDeinitLeavesReapOwnershipWithObserver root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testDeinitLeavesReapOwnershipWithObserver AgentMode/Codex codex.app_server.exit_observer.deinit_reap deinit,sole_reaper,zombie_prevention subprocess_lifecycle_regression root_swiftpm routine 1 persistent_codex_fixture Dropping the client leaves the detached observer as the only reaper and waitpid reaches ECHILD without the test consuming status. Client deinit could cancel reap ownership and strand a zombie app-server process. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 sole-reaper lifecycle coverage. +root/RepoPromptTests.CodexAppServerClientProcessExitTests/testExplicitStopWinsOverObservedTermination root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testExplicitStopWinsOverObservedTermination AgentMode/Codex codex.app_server.lifecycle.explicit_stop_precedence explicit_stop,observer_exit,first_claim subprocess_lifecycle_regression root_swiftpm routine 1 persistent_codex_fixture A blocked request receives processNotRunning and the generation retains explicitStop after TERM produces an observed exit. Intentional stop could be mislabeled as an unexpected process crash. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 lifecycle-precedence coverage. +root/RepoPromptTests.CodexAppServerClientProcessExitTests/testListModelsRetriesTypedProcessExitOnceOnFreshProcess root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testListModelsRetriesTypedProcessExitOnceOnFreshProcess AgentMode/Codex codex.app_server.model_list.typed_exit_retry model_list,process_exit,retry,fresh_process subprocess_lifecycle_regression root_swiftpm routine 1 exit_then_model_codex_fixture A typed processExited failure during model/list receives one fresh-process retry and returns the successful replacement process model response. Typed exit recovery could regress from the established EOF recovery behavior and leave model discovery unavailable after a transient app-server exit. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 typed-exit model-list recovery coverage. +root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStaleObservedExitCannotMutateReplacementGeneration root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStaleObservedExitCannotMutateReplacementGeneration AgentMode/Codex codex.app_server.lifecycle.stale_generation_exit_rejection generation,observer_identity,replacement async_concurrency_lifecycle root_swiftpm routine 1 persistent_codex_fixture A delivered old-generation exit leaves the replacement PID, generation, running state, and termination reason unchanged. Late observer callbacks could poison a replacement transport. subprocess;filesystem;actor test_case+fixture_cleanup retain 0 WI-2 generation-safety coverage. +root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStopDuringRestartPreparationPreventsSpawnAfterReturn root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStopDuringRestartPreparationPreventsSpawnAfterReturn AgentMode/Codex codex.app_server.lifecycle.stop_restart_spawn_fence stop,restart,startup_cancellation,pre_spawn,termination_retirement async_concurrency_lifecycle root_swiftpm routine 1 persistent_codex_fixture,ProcessSpawnPreparationGate A stop during blocked restart preparation returns only after revoking startup authority; releasing preparation afterward cannot produce a second process spawn or live transport. A settled teardown task could let stop return without cancelling a restart, allowing an app-server to spawn and survive after shutdown. subprocess;filesystem;actor;async_wait test_case+fixture_cleanup retain 0 WI-2 restart pre-spawn shutdown regression coverage. +root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStartupEOFReturnsTypedExitWithSettledBoundedStderr root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStartupEOFReturnsTypedExitWithSettledBoundedStderr AgentMode/Codex codex.app_server.process_exit.typed_stderr_evidence stdout_eof,exit_status,stderr_tail,truncation,descendant_stderr,family_cleanup,first_claim,shared_completion subprocess_lifecycle_regression root_swiftpm routine 2 early_exit_codex_fixture EOF before exit performs bounded family cleanup and stderr drain before returning processExited with the exact final 8 KiB raw suffix; a losing stop remains pending until the winning settlement completes. Startup failures could snapshot incomplete descendant-held stderr, lose actionable evidence, or let duplicate lifecycle callers return before teardown completes. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 bounded startup-diagnostic, descendant cleanup, and shared-completion coverage. +root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStartupSignalExitKeepsSignalSemanticsAndOmitsEmptyStderr root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStartupSignalExitKeepsSignalSemanticsAndOmitsEmptyStderr AgentMode/Codex codex.app_server.process_exit.signal_evidence signal,empty_stderr,typed_exit subprocess_lifecycle_regression root_swiftpm routine 1 early_exit_codex_fixture A SIGKILL startup exit remains uncaughtSignal(SIGKILL), retains empty raw stderr, and omits stderr from rendered diagnostics. Signal exits could be normalized into ambiguous integer codes or render empty diagnostic noise. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 typed exit-evidence coverage. +root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStderrCaptureRetainsExactRawSuffixAtEveryBoundary root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStderrCaptureRetainsExactRawSuffixAtEveryBoundary AgentMode/Codex codex.app_server.stderr_tail.byte_boundaries stderr,byte_cap,truncation,invalid_utf8,multichunk deterministic_unit root_swiftpm fast 6 CodexProcessStderrCapture Empty, 1, 8191, 8192, 8193, and multi-chunk invalid-UTF-8 inputs retain the exact expected suffix and truncation bit. Boundary drift could lose bytes, overrun the cap, or decode evidence before rendering. test_case retain 0 WI-2 raw stderr capture coverage. +root/RepoPromptTests.CodexAppServerClientProcessExitTests/testTimeoutPoisoningWinsOverObservedTermination root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testTimeoutPoisoningWinsOverObservedTermination AgentMode/Codex codex.app_server.lifecycle.timeout_precedence timeout,transport_poisoning,observer_exit,first_claim subprocess_lifecycle_regression root_swiftpm routine 1 persistent_codex_fixture A timed-out thread/start retains requestFailed timeout and the timeout termination reason after observer-driven teardown. Timeout poisoning could be relabeled as a process crash and lose its request-level meaning. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 lifecycle-precedence coverage. +root/RepoPromptTests.CodexAppServerClientProcessExitTests/testTransportWriteFailureWinsOverObservedTermination root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testTransportWriteFailureWinsOverObservedTermination AgentMode/Codex codex.app_server.lifecycle.stdin_write_precedence stdin,EPIPE,observer_exit,first_claim subprocess_lifecycle_regression root_swiftpm routine 1 persistent_codex_fixture,FDWriteError An injected EPIPE during initialize remains transportWriteFailed with stdinWrite lifecycle reason after observer-owned teardown. A write failure could collapse to processNotRunning or processExited and hide the actionable errno. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 lifecycle-precedence coverage. root/RepoPromptTests.CodexAgentModeCoordinatorLivenessTests/testAcceptedSteerRemainsSentWhenMatchingTurnCompletesBeforeReceiptResumes root Tests/RepoPromptTests/AgentMode/Codex/CodexAgentModeCoordinatorLivenessTests.swift RepoPromptTests.CodexAgentModeCoordinatorLivenessTests testAcceptedSteerRemainsSentWhenMatchingTurnCompletesBeforeReceiptResumes AgentMode unreviewed unreviewed root_swiftpm integration 1 unreviewed unreviewed 0.064500 unreviewed retain_pending_review 0 initial census source line 588 root/RepoPromptTests.CodexAgentModeCoordinatorLivenessTests/testActiveCodexNativeSendFailsWithoutSendingWhenAgentRunDrainFails root Tests/RepoPromptTests/AgentMode/Codex/CodexAgentModeCoordinatorLivenessTests.swift RepoPromptTests.CodexAgentModeCoordinatorLivenessTests testActiveCodexNativeSendFailsWithoutSendingWhenAgentRunDrainFails AgentMode unreviewed unreviewed root_swiftpm integration 1 unreviewed unreviewed 0.001500 unreviewed retain_pending_review 0 initial census source line 470 root/RepoPromptTests.CodexAgentModeCoordinatorLivenessTests/testActiveCodexNativeSendUsesRealAgentRunDrainBeforeSending root Tests/RepoPromptTests/AgentMode/Codex/CodexAgentModeCoordinatorLivenessTests.swift RepoPromptTests.CodexAgentModeCoordinatorLivenessTests testActiveCodexNativeSendUsesRealAgentRunDrainBeforeSending AgentMode unreviewed unreviewed root_swiftpm integration 1 unreviewed unreviewed 0.034000 unreviewed retain_pending_review 0 initial census source line 412 diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift index 5d3f00dd4..ba8dcc371 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift @@ -179,8 +179,19 @@ actor CodexAppServerClient { let params: [String: CodexJSONValue] } + struct ProcessExitEvidence: Equatable { + let executablePath: String + let launchDirectory: String + let pid: pid_t + let status: ProcessExitStatus + let stderrTail: Data + let stderrWasTruncated: Bool + let stderrWasSettled: Bool + } + enum ClientError: Error, LocalizedError { case processNotRunning + case processExited(ProcessExitEvidence) case invalidResponse case jsonDecodeFailed case requestFailed(RequestFailure) @@ -192,6 +203,8 @@ actor CodexAppServerClient { switch self { case .processNotRunning: "Codex app-server process is not running." + case let .processExited(evidence): + Self.processExitDescription(evidence) case .invalidResponse: "Codex app-server returned an invalid response." case .jsonDecodeFailed: @@ -206,6 +219,25 @@ actor CodexAppServerClient { message } } + + private static func processExitDescription(_ evidence: ProcessExitEvidence) -> String { + let outcome = switch evidence.status { + case let .exited(code): + "exited with status \(code)" + case let .uncaughtSignal(signal): + "terminated from signal \(signal)" + } + var description = "Codex app-server \(outcome) while running \(evidence.executablePath) in \(evidence.launchDirectory)." + if !evidence.stderrTail.isEmpty { + let stderr = String(decoding: evidence.stderrTail, as: UTF8.self) + let suffix = evidence.stderrWasTruncated ? " (tail truncated)" : "" + description += " stderr\(suffix): \(stderr)" + } + if !evidence.stderrWasSettled { + description += " Stderr capture did not settle after bounded process-family cleanup." + } + return description + } } enum TransportTerminationReason: Equatable { @@ -216,6 +248,7 @@ actor CodexAppServerClient { case livenessCheckFailed(method: String?) case decodeRecoveryBudgetExceeded(generation: UInt64) case readSourceSetupFailed(stream: String, errno: Int32?) + case observedProcessExit(status: ProcessExitStatus) } struct ExpectedAgentPIDRegistration: Equatable { @@ -248,9 +281,23 @@ actor CodexAppServerClient { let transportGeneration: UInt64 } + private struct ExitObservation { + let observer: ChildProcessExitObserver + let executablePath: String + let launchDirectory: String + let stderrCapture: CodexProcessStderrCapture + } + + private struct ActiveTransport { + let generation: UInt64 + let process: SpawnedProcess + let exitObservation: ExitObservation? + } + private struct TerminatingTransport { - let process: SpawnedProcess? + let activeTransport: ActiveTransport? let expectedAgentPIDToClear: RegisteredExpectedAgentPID? + let processFamilyCleanupWasCompleted: Bool } static func isTimeoutError(_ error: Error) -> Bool { @@ -308,7 +355,7 @@ actor CodexAppServerClient { } private var config = Config() - private var process: SpawnedProcess? + private var activeTransport: ActiveTransport? private var stdoutChunkChannel: FileHandleChunkChannel? private var stderrChunkChannel: FileHandleChunkChannel? private var stdoutConsumerTask: Task? @@ -324,6 +371,8 @@ actor CodexAppServerClient { private var stdoutTail = Data() private var didTerminateTransport = false private var lastTransportTerminationReason: TransportTerminationReason? + private var lastTransportFailure: ClientError? + private var transportTerminationTask: (generation: UInt64, task: Task)? /// Per-transport decode-recovery attempts, used to cap CPU spent on malformed lines. private var decodeRecoveryAttemptsByGeneration: [UInt64: Int] = [:] /// Monotonic counter incremented each time a new process is started. @@ -335,8 +384,11 @@ actor CodexAppServerClient { private var expectedAgentPIDRegistration: ExpectedAgentPIDRegistration? private var registeredExpectedAgentPID: RegisteredExpectedAgentPID? private static let maxDecodeRecoveryAttemptsPerGeneration = 128 + private static let stderrTailLimit = 8 * 1024 + private static let exitDiagnosticSettlementWindow: TimeInterval = 0.25 private let writeFrameHandler: @Sendable (Int32, Data) throws -> Void private let livenessProbe: @Sendable (SpawnedProcess) -> Bool + private let processSpawnPreparation: @Sendable () async throws -> Void private let expectedAgentPIDRegistrar: ExpectedAgentPIDRegistrar deinit { @@ -350,10 +402,12 @@ actor CodexAppServerClient { livenessProbe: @escaping @Sendable (SpawnedProcess) -> Bool = { process in CodexAppServerClient.defaultProcessAppearsAlive(process) }, + processSpawnPreparation: @escaping @Sendable () async throws -> Void = {}, expectedAgentPIDRegistrar: ExpectedAgentPIDRegistrar = .serverNetworkManager ) { self.writeFrameHandler = writeFrameHandler self.livenessProbe = livenessProbe + self.processSpawnPreparation = processSpawnPreparation self.expectedAgentPIDRegistrar = expectedAgentPIDRegistrar } @@ -367,11 +421,11 @@ actor CodexAppServerClient { await clearRegisteredExpectedAgentPIDIfNeeded() return } - guard let process else { + guard let activeTransport else { await clearRegisteredExpectedAgentPIDIfNeeded() return } - await registerExpectedAgentPIDIfNeeded(for: process.pid) + await registerExpectedAgentPIDIfNeeded(for: activeTransport.process.pid) } func clearExpectedAgentPIDRegistration() async { @@ -380,7 +434,11 @@ actor CodexAppServerClient { } private func registerExpectedAgentPIDIfNeeded(for pid: pid_t) async { - guard let registration = expectedAgentPIDRegistration else { return } + guard !didTerminateTransport, + let registration = expectedAgentPIDRegistration + else { + return + } let target = RegisteredExpectedAgentPID( pid: pid, clientName: registration.clientName, @@ -388,10 +446,10 @@ actor CodexAppServerClient { ) guard registeredExpectedAgentPID != target else { return } await clearRegisteredExpectedAgentPIDIfNeeded() - guard expectedAgentPIDRegistration == registration, process?.pid == pid else { return } + guard expectedAgentPIDRegistration == registration, activeTransport?.process.pid == pid else { return } registeredExpectedAgentPID = target await expectedAgentPIDRegistrar.register(target.pid, target.clientName, target.runID) - guard expectedAgentPIDRegistration == registration, process?.pid == pid else { + guard expectedAgentPIDRegistration == registration, activeTransport?.process.pid == pid else { if registeredExpectedAgentPID == target { registeredExpectedAgentPID = nil } @@ -437,7 +495,7 @@ actor CodexAppServerClient { featurePolicy: featurePolicy, modelReasoningSummary: modelReasoningSummary ) - if process != nil { + if activeTransport != nil { await terminateTransport(flushStdout: true, reason: .explicitStop) } } @@ -463,21 +521,24 @@ actor CodexAppServerClient { } func startIfNeeded() async throws { + if let termination = transportTerminationTask, + termination.generation == transportGeneration + { + await termination.task.value + } if let existingStartupTask = startupTask?.task { return try await existingStartupTask.value } - if let process { - let appearsAlive = livenessProbe(process) + if let activeTransport { + let appearsAlive = livenessProbe(activeTransport.process) if isInitialized, appearsAlive { return } if !appearsAlive { - scheduleTransportCleanup( - invalidateTransport( - flushStdout: false, - requestFailure: .processNotRunning, - reason: .livenessCheckFailed(method: nil) - ) + await settleObservationalTermination( + generation: activeTransport.generation, + flushStdout: false, + fallbackReason: .livenessCheckFailed(method: nil) ) } } @@ -502,6 +563,12 @@ actor CodexAppServerClient { func stop() async { startupTask?.task.cancel() startupTask = nil + if let termination = transportTerminationTask, + termination.generation == transportGeneration + { + await termination.task.value + return + } await terminateTransport(flushStdout: true, reason: .explicitStop) } @@ -531,28 +598,105 @@ actor CodexAppServerClient { requestFailure: ClientError = .processNotRunning, reason: TransportTerminationReason ) async { - await finishTransportTermination( - invalidateTransport( - flushStdout: flushStdout, - expectedGeneration: expectedGeneration, - requestFailure: requestFailure, - reason: reason - ) + let task = beginTransportTermination( + flushStdout: flushStdout, + expectedGeneration: expectedGeneration, + requestFailure: requestFailure, + reason: reason ) + await task?.value } - private func invalidateTransport( + @discardableResult + private func beginTransportTermination( flushStdout: Bool, expectedGeneration: UInt64? = nil, requestFailure: ClientError, reason: TransportTerminationReason - ) -> TerminatingTransport? { - if let expected = expectedGeneration, expected != transportGeneration { return nil } - guard !didTerminateTransport else { return nil } + ) -> Task? { + if let expectedGeneration, expectedGeneration != transportGeneration { return nil } + if let existing = transportTerminationTask, + existing.generation == transportGeneration + { + return existing.task + } + guard claimTransportTermination(reason: reason) else { return nil } + lastTransportFailure = requestFailure + let generation = transportGeneration + let task = Task { [weak self] in + guard let self else { return } + await completeTransportTermination( + generation: generation, + flushStdout: flushStdout, + requestFailure: requestFailure + ) + } + transportTerminationTask = (generation: generation, task: task) + return task + } + + private func beginObservedProcessExitTermination( + status: ProcessExitStatus, + observer: ChildProcessExitObserver, + generation: UInt64 + ) -> Task? { + guard let activeTransport, + activeTransport.generation == generation, + activeTransport.exitObservation?.observer === observer, + activeTransport.process.pid == observer.pid + else { + return nil + } + if let existing = transportTerminationTask, + existing.generation == generation + { + return existing.task + } + guard claimTransportTermination(reason: .observedProcessExit(status: status)) else { + return nil + } + let expectedAgentPIDToClear = takeRegisteredExpectedAgentPIDForDeferredClear() + let task = Task { [weak self] in + guard let self else { return } + await completeObservedProcessExitTermination( + status: status, + observer: observer, + generation: generation, + expectedAgentPIDToClear: expectedAgentPIDToClear + ) + } + transportTerminationTask = (generation: generation, task: task) + return task + } + + private func claimTransportTermination(reason: TransportTerminationReason) -> Bool { + guard !didTerminateTransport else { return false } didTerminateTransport = true + isInitialized = false lastTransportTerminationReason = reason + return true + } + + private func completeTransportTermination( + generation: UInt64, + flushStdout: Bool, + requestFailure: ClientError + ) async { + guard generation == transportGeneration else { return } + defer { retireTransportTermination(generation: generation) } + await finishTransportTermination( + invalidateClaimedTransport( + flushStdout: flushStdout, + requestFailure: requestFailure + ) + ) + } - // 1. Flush remaining stdout lines before tearing down. + private func invalidateClaimedTransport( + flushStdout: Bool, + requestFailure: ClientError, + processFamilyCleanupWasCompleted: Bool = false + ) -> TerminatingTransport { if flushStdout { var remainingLines: [Data] = [] stdoutFramer.flush { lineData in @@ -563,17 +707,16 @@ actor CodexAppServerClient { } } - // 2. Tear down chunk channels and consumer tasks. stdoutChunkChannel?.finish() stderrChunkChannel?.finish() stdoutConsumerTask?.cancel() stderrConsumerTask?.cancel() + activeTransport?.exitObservation?.stderrCapture.finish() stdoutChunkChannel = nil stderrChunkChannel = nil stdoutConsumerTask = nil stderrConsumerTask = nil - // 3. Cancel all timeout tasks and fail all pending requests. for task in timeoutTasks.values { task.cancel() } @@ -585,7 +728,6 @@ actor CodexAppServerClient { continuation.resume(throwing: requestFailure) } - // 4. Finish all notification and serverRequest subscriber streams. let notifContinuations = notificationContinuations notificationContinuations.removeAll() for continuation in notifContinuations.values { @@ -598,31 +740,142 @@ actor CodexAppServerClient { continuation.finish() } - // 5. Snapshot and nil process BEFORE the await to prevent actor re-entrancy - // issues: other calls (startIfNeeded, request, subscribe*) that interleave - // during ProcessTermination.terminateAndReap will see process==nil and - // correctly fail/bail out. - let terminatingProcess = process - let expectedAgentPIDToClear = takeRegisteredExpectedAgentPIDForDeferredClear() - process = nil - isInitialized = false + let terminatingTransport = TerminatingTransport( + activeTransport: activeTransport, + expectedAgentPIDToClear: takeRegisteredExpectedAgentPIDForDeferredClear(), + processFamilyCleanupWasCompleted: processFamilyCleanupWasCompleted + ) + activeTransport = nil - // 6. Reset framer state for potential future restart. stdoutFramer = LineFramer() stdoutTail.removeAll(keepingCapacity: false) decodeRecoveryAttemptsByGeneration.removeValue(forKey: transportGeneration) + return terminatingTransport + } - return TerminatingTransport( - process: terminatingProcess, - expectedAgentPIDToClear: expectedAgentPIDToClear + private func settleObservationalTermination( + generation: UInt64, + flushStdout: Bool, + fallbackReason: TransportTerminationReason + ) async { + guard generation == transportGeneration else { return } + if let existing = transportTerminationTask, + existing.generation == generation + { + await existing.task.value + return + } + if let observer = activeTransport?.exitObservation?.observer, + let outcome = await observer.wait(timeout: Self.exitDiagnosticSettlementWindow) + { + await handleObservedProcessExit( + outcome, + observer: observer, + generation: generation + ) + return + } + await terminateTransport( + flushStdout: flushStdout, + expectedGeneration: generation, + reason: fallbackReason ) } - private func scheduleTransportCleanup(_ terminatingTransport: TerminatingTransport?) { - guard let terminatingTransport else { return } - Task { - await self.finishTransportTermination(terminatingTransport) + private func handleObservedProcessExit( + _ outcome: ChildProcessExitObserver.Outcome, + observer: ChildProcessExitObserver, + generation: UInt64 + ) async { + guard let activeTransport, + activeTransport.generation == generation, + activeTransport.exitObservation?.observer === observer, + activeTransport.process.pid == observer.pid + else { + return + } + let task: Task? = switch outcome { + case let .exited(status): + beginObservedProcessExitTermination( + status: status, + observer: observer, + generation: generation + ) + case .failed: + beginTransportTermination( + flushStdout: true, + expectedGeneration: generation, + requestFailure: .processNotRunning, + reason: .livenessCheckFailed(method: nil) + ) + } + await task?.value + } + + private func completeObservedProcessExitTermination( + status: ProcessExitStatus, + observer: ChildProcessExitObserver, + generation: UInt64, + expectedAgentPIDToClear: RegisteredExpectedAgentPID? + ) async { + defer { retireTransportTermination(generation: generation) } + if let expectedAgentPIDToClear { + await expectedAgentPIDRegistrar.clear( + expectedAgentPIDToClear.pid, + expectedAgentPIDToClear.clientName, + expectedAgentPIDToClear.runID + ) + } + guard let transport = activeTransport, + transport.generation == generation, + let exitObservation = transport.exitObservation, + exitObservation.observer === observer, + transport.process.pid == observer.pid + else { + return + } + let logger: (String) -> Void = config.enableDebugLogging + ? { print("[CodexAppServer] \($0)") } + : { _ in } + await ProcessTermination.terminateObservedProcessFamily( + observer: observer, + processGroupID: transport.process.processGroupID, + logger: logger + ) + let stderrWasSettled = await exitObservation.stderrCapture.waitUntilFinished( + timeout: Self.exitDiagnosticSettlementWindow + ) + guard let currentTransport = activeTransport, + currentTransport.generation == generation, + currentTransport.exitObservation?.observer === observer, + currentTransport.process.pid == observer.pid + else { + return } + + let stderr = exitObservation.stderrCapture.snapshot() + let evidence = ProcessExitEvidence( + executablePath: exitObservation.executablePath, + launchDirectory: exitObservation.launchDirectory, + pid: observer.pid, + status: status, + stderrTail: stderr.bytes, + stderrWasTruncated: stderr.wasTruncated, + stderrWasSettled: stderrWasSettled + ) + lastTransportFailure = .processExited(evidence) + await finishTransportTermination( + invalidateClaimedTransport( + flushStdout: true, + requestFailure: .processExited(evidence), + processFamilyCleanupWasCompleted: true + ) + ) + } + + private func retireTransportTermination(generation: UInt64) { + guard transportTerminationTask?.generation == generation else { return } + transportTerminationTask = nil } private func emergencyTerminateTransportForDeinit() { @@ -666,15 +919,25 @@ actor CodexAppServerClient { ) } } - guard let process else { return } - self.process = nil + guard let activeTransport else { return } + self.activeTransport = nil + activeTransport.exitObservation?.stderrCapture.finish() + let process = activeTransport.process process.stdout.readabilityHandler = nil process.stderr.readabilityHandler = nil process.stdin?.closeFile() - let pid = process.pid - let processGroupID = process.processGroupID Task.detached { - _ = await ProcessTermination.terminateAndReap(pid: pid, processGroupID: processGroupID) + if let observer = activeTransport.exitObservation?.observer { + await ProcessTermination.terminateObservedProcessFamily( + observer: observer, + processGroupID: process.processGroupID + ) + } else { + _ = await ProcessTermination.terminateAndReap( + pid: process.pid, + processGroupID: process.processGroupID + ) + } } } @@ -687,16 +950,30 @@ actor CodexAppServerClient { expectedAgentPIDToClear.runID ) } - guard let process = terminatingTransport.process else { return } + guard let activeTransport = terminatingTransport.activeTransport else { return } + let process = activeTransport.process process.stdout.readabilityHandler = nil process.stderr.readabilityHandler = nil process.stdin?.closeFile() - let pid = process.pid - _ = await ProcessTermination.terminateAndReap( - pid: pid, - processGroupID: process.processGroupID, - logger: config.enableDebugLogging ? { print("[CodexAppServer] \($0)") } : { _ in } - ) + let logger: (String) -> Void = config.enableDebugLogging + ? { print("[CodexAppServer] \($0)") } + : { _ in } + if terminatingTransport.processFamilyCleanupWasCompleted { + return + } + if let observer = activeTransport.exitObservation?.observer { + await ProcessTermination.terminateObservedProcessFamily( + observer: observer, + processGroupID: process.processGroupID, + logger: logger + ) + } else { + _ = await ProcessTermination.terminateAndReap( + pid: process.pid, + processGroupID: process.processGroupID, + logger: logger + ) + } } private static func defaultProcessAppearsAlive(_ process: SpawnedProcess) -> Bool { @@ -739,9 +1016,11 @@ actor CodexAppServerClient { useDefaultTimeout: Bool ) async throws -> [String: Any] { try Task.checkCancellation() - guard process != nil else { throw ClientError.processNotRunning } + guard let activeTransport, !didTerminateTransport else { + throw lastTransportFailure ?? ClientError.processNotRunning + } let requestID = makeRequestID() - let generation = transportGeneration + let generation = activeTransport.generation let deadline = timeout ?? (useDefaultTimeout ? config.requestTimeout : nil) var payload: [String: Any] = [ "method": method, @@ -776,7 +1055,9 @@ actor CodexAppServerClient { } func respondToServerRequest(id: CodexAppServerRequestID, result: [String: Any]) throws { - guard process != nil else { throw ClientError.processNotRunning } + guard activeTransport != nil, !didTerminateTransport else { + throw lastTransportFailure ?? ClientError.processNotRunning + } let payload: [String: Any] = [ "id": id.jsonValue, "result": result @@ -790,7 +1071,9 @@ actor CodexAppServerClient { message: String, data: [String: Any]? = nil ) throws { - guard process != nil else { throw ClientError.processNotRunning } + guard activeTransport != nil, !didTerminateTransport else { + throw lastTransportFailure ?? ClientError.processNotRunning + } var errorObject: [String: Any] = [ "code": code, "message": message @@ -806,7 +1089,9 @@ actor CodexAppServerClient { } func notify(method: String, params: [String: Any]?) throws { - guard process != nil else { throw ClientError.processNotRunning } + guard activeTransport != nil, !didTerminateTransport else { + throw lastTransportFailure ?? ClientError.processNotRunning + } var payload: [String: Any] = [ "method": method ] @@ -822,7 +1107,7 @@ actor CodexAppServerClient { return try await fetchModelPages(limit: limit) } catch let error as ClientError { switch error { - case .processNotRunning, .transportWriteFailed, .transportReadSetupFailed: + case .processNotRunning, .processExited, .transportWriteFailed, .transportReadSetupFailed: return try await fetchModelPages(limit: limit) default: throw error @@ -920,10 +1205,20 @@ actor CodexAppServerClient { } private func performStartupIfNeeded() async throws { - if process == nil { - try await startProcess() + do { + if activeTransport == nil { + try await startProcess() + } + try await initializeIfNeeded() + } catch is CancellationError { + if let termination = transportTerminationTask, + termination.generation == transportGeneration + { + await termination.task.value + throw lastTransportFailure ?? ClientError.processNotRunning + } + throw CancellationError() } - try await initializeIfNeeded() } private func startProcess() async throws { @@ -951,34 +1246,73 @@ actor CodexAppServerClient { featurePolicy: config.processFeaturePolicy ) let args = processOverrides + ["app-server"] + let launchDirectory = config.processLaunchDirectory ?? FileManager.default.currentDirectoryPath + try await processSpawnPreparation() + try Task.checkCancellation() let spawned = try ProcessLauncher.spawn( command: resolution.resolvedCommand, arguments: args, environment: environment, - workingDirectory: config.processLaunchDirectory + workingDirectory: launchDirectory ) + + // The observer is installed before reader setup or PID registration so + // every successful spawn immediately has one cancellation-independent reaper. + transportGeneration &+= 1 + let generation = transportGeneration + let exitObserver = ChildProcessExitObserver(pid: spawned.pid) + let capture = CodexProcessStderrCapture(byteLimit: Self.stderrTailLimit) + stdoutFramer = LineFramer() stdoutTail.removeAll(keepingCapacity: false) didTerminateTransport = false - transportGeneration &+= 1 - decodeRecoveryAttemptsByGeneration[transportGeneration] = 0 - process = spawned + lastTransportTerminationReason = nil + lastTransportFailure = nil + transportTerminationTask = nil + decodeRecoveryAttemptsByGeneration[generation] = 0 + activeTransport = ActiveTransport( + generation: generation, + process: spawned, + exitObservation: ExitObservation( + observer: exitObserver, + executablePath: resolution.resolvedCommand, + launchDirectory: launchDirectory, + stderrCapture: capture + ) + ) + + Task.detached { [weak self] in + guard let outcome = await exitObserver.wait() else { return } + await self?.handleObservedProcessExit( + outcome, + observer: exitObserver, + generation: generation + ) + } do { - try startStdoutReader(spawned.stdout) - try startStderrReader(spawned.stderr) + try startStdoutReader(spawned.stdout, generation: generation) + try startStderrReader(spawned.stderr, capture: capture) } catch { let clientError = Self.transportReadSetupError(stream: "process pipe", error: error) - let terminatingTransport = invalidateTransport( + let task = beginTransportTermination( flushStdout: false, requestFailure: clientError, reason: .readSourceSetupFailed(stream: "process pipe", errno: Self.errnoValue(from: error)) ) - await finishTransportTermination(terminatingTransport) - throw clientError + await task?.value + throw lastTransportFailure ?? clientError } await registerExpectedAgentPIDIfNeeded(for: spawned.pid) - guard process?.pid == spawned.pid, !didTerminateTransport else { - throw ClientError.processNotRunning + guard activeTransport?.process.pid == spawned.pid, + activeTransport?.generation == generation, + !didTerminateTransport + else { + if let termination = transportTerminationTask, + termination.generation == generation + { + await termination.task.value + } + throw lastTransportFailure ?? ClientError.processNotRunning } } @@ -992,7 +1326,7 @@ actor CodexAppServerClient { /// Related: /// - FileHandleChunkChannel (FIFO ordering primitive) /// - ClaudeNativeProcessSessionController.startStdoutReader (reference implementation) - private func startStdoutReader(_ handle: FileHandle) throws { + private func startStdoutReader(_ handle: FileHandle, generation: UInt64) throws { try ReadSourceFDPreflight.validateOpenFD(handle.fileDescriptor, label: "Codex app-server stdout") stdoutConsumerTask?.cancel() stdoutConsumerTask = nil @@ -1007,11 +1341,10 @@ actor CodexAppServerClient { channel.yield(data) } } - let generation = transportGeneration stdoutConsumerTask = Task { [weak self] in for await chunk in channel.stream { guard let self else { break } - await handleStdoutChunk(chunk) + await handleStdoutChunk(chunk, generation: generation) } // Stream ended — could be genuine EOF or cancellation/finish from teardown. // Only trigger teardown on genuine EOF (not cancellation), and scope to @@ -1023,7 +1356,7 @@ actor CodexAppServerClient { } /// Sets up a FIFO channel + single consumer task for stderr. - private func startStderrReader(_ handle: FileHandle) throws { + private func startStderrReader(_ handle: FileHandle, capture: CodexProcessStderrCapture) throws { try ReadSourceFDPreflight.validateOpenFD(handle.fileDescriptor, label: "Codex app-server stderr") stderrConsumerTask?.cancel() stderrConsumerTask = nil @@ -1039,20 +1372,19 @@ actor CodexAppServerClient { channel.yield(data) } } - stderrConsumerTask = Task { [weak self] in + stderrConsumerTask = Task { + defer { capture.finish() } for await chunk in channel.stream { - guard self != nil else { break } - if enableDebugLogging, - let line = String(data: chunk, encoding: .utf8), - !line.isEmpty - { - print("[CodexAppServer][stderr] \(line)") + capture.append(chunk) + if enableDebugLogging, !chunk.isEmpty { + print("[CodexAppServer][stderr] \(String(decoding: chunk, as: UTF8.self))") } } } } - private func handleStdoutChunk(_ data: Data) async { + private func handleStdoutChunk(_ data: Data, generation: UInt64) async { + guard activeTransport?.generation == generation, !didTerminateTransport else { return } appendTail(&stdoutTail, chunk: data, limit: 128 * 1024) stdoutFramer.feed(data, onDiagnostic: { [self] diagnostic in handleStdoutFramerDiagnostic(diagnostic) @@ -1065,10 +1397,10 @@ actor CodexAppServerClient { /// Delegates to `terminateTransport` for authoritative cleanup, scoped to the /// transport generation that created the consumer task. private func handleStdoutEOF(generation: UInt64) async { - await terminateTransport( + await settleObservationalTermination( + generation: generation, flushStdout: true, - expectedGeneration: generation, - reason: .stdoutEOF + fallbackReason: .stdoutEOF ) } @@ -1427,7 +1759,10 @@ actor CodexAppServerClient { /// Related: /// - ClaudeNativeProcessSessionController.sendLine (reference atomic write pattern) private func sendJSONLine(_ payload: [String: Any], method: String?) throws { - guard let process else { throw ClientError.processNotRunning } + guard let activeTransport, !didTerminateTransport else { + throw lastTransportFailure ?? ClientError.processNotRunning + } + let process = activeTransport.process let data = try JSONSerialization.data(withJSONObject: payload, options: []) if config.enableDebugLogging { if let line = String(data: data, encoding: .utf8) { @@ -1439,7 +1774,7 @@ actor CodexAppServerClient { } var frame = data frame.append(0x0A) - let generation = transportGeneration + let generation = activeTransport.generation do { try writeFrameHandler(stdinDescriptor, frame) } catch let error as FDWriteError { @@ -1447,13 +1782,11 @@ actor CodexAppServerClient { message: transportWriteFailureMessage(method: method, errno: error.errnoValue), errno: error.errnoValue ) - scheduleTransportCleanup( - invalidateTransport( - flushStdout: false, - expectedGeneration: generation, - requestFailure: failure, - reason: .stdinWrite(method: method, errno: error.errnoValue) - ) + beginTransportTermination( + flushStdout: false, + expectedGeneration: generation, + requestFailure: failure, + reason: .stdinWrite(method: method, errno: error.errnoValue) ) throw failure } catch { @@ -1461,13 +1794,11 @@ actor CodexAppServerClient { message: transportWriteFailureMessage(method: method, errno: nil), errno: nil ) - scheduleTransportCleanup( - invalidateTransport( - flushStdout: false, - expectedGeneration: generation, - requestFailure: failure, - reason: .stdinWrite(method: method, errno: nil) - ) + beginTransportTermination( + flushStdout: false, + expectedGeneration: generation, + requestFailure: failure, + reason: .stdinWrite(method: method, errno: nil) ) throw failure } @@ -1531,13 +1862,12 @@ actor CodexAppServerClient { if let metadata, Self.shouldPoisonTransportOnTimeout(method: metadata.method) { - let terminatingTransport = invalidateTransport( + beginTransportTermination( flushStdout: false, expectedGeneration: metadata.transportGeneration, requestFailure: .processNotRunning, reason: .timeout(method: metadata.method, requestID: id) ) - scheduleTransportCleanup(terminatingTransport) } continuation.resume(throwing: ClientError.requestFailed(.init( method: metadata?.method ?? "", @@ -1583,11 +1913,11 @@ actor CodexAppServerClient { #if DEBUG func debugProcessID() -> pid_t? { - process?.pid + activeTransport?.process.pid } func debugIsProcessRunning() -> Bool { - process != nil + activeTransport != nil && !didTerminateTransport } func debugNextRequestID() -> Int { @@ -1602,6 +1932,22 @@ actor CodexAppServerClient { lastTransportTerminationReason } + func debugProcessExitObserver() -> ChildProcessExitObserver? { + activeTransport?.exitObservation?.observer + } + + func debugDeliverObservedProcessExit( + _ outcome: ChildProcessExitObserver.Outcome, + observer: ChildProcessExitObserver, + generation: UInt64 + ) async { + await handleObservedProcessExit( + outcome, + observer: observer, + generation: generation + ) + } + static func debugDefaultProcessAppearsAlive(_ process: SpawnedProcess) -> Bool { defaultProcessAppearsAlive(process) } @@ -1631,18 +1977,24 @@ actor CodexAppServerClient { let stdinPipe = Pipe() let stdoutPipe = Pipe() let stderrPipe = Pipe() - process = SpawnedProcess( - pid: pid_t.max, - processGroupID: nil, - stdin: stdinPipe.fileHandleForWriting, - stdinDescriptor: stdinPipe.fileHandleForWriting.fileDescriptor, - stdout: stdoutPipe.fileHandleForReading, - stderr: stderrPipe.fileHandleForReading + transportGeneration &+= 1 + activeTransport = ActiveTransport( + generation: transportGeneration, + process: SpawnedProcess( + pid: pid_t.max, + processGroupID: nil, + stdin: stdinPipe.fileHandleForWriting, + stdinDescriptor: stdinPipe.fileHandleForWriting.fileDescriptor, + stdout: stdoutPipe.fileHandleForReading, + stderr: stderrPipe.fileHandleForReading + ), + exitObservation: nil ) isInitialized = true didTerminateTransport = false lastTransportTerminationReason = nil - transportGeneration &+= 1 + lastTransportFailure = nil + transportTerminationTask = nil decodeRecoveryAttemptsByGeneration[transportGeneration] = 0 } diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexProcessStderrCapture.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexProcessStderrCapture.swift new file mode 100644 index 000000000..1ee6059fd --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexProcessStderrCapture.swift @@ -0,0 +1,99 @@ +import Foundation + +/// Generation-local raw stderr tail. The capture never decodes evidence and +/// exposes a bounded completion wait without cancelling the producer. +final class CodexProcessStderrCapture: @unchecked Sendable { + struct Snapshot: Equatable { + let bytes: Data + let wasTruncated: Bool + } + + private let lock = NSLock() + private let byteLimit: Int + private var tail = Data() + private var wasTruncated = false + private var isFinished = false + private var waiters: [UUID: CheckedContinuation] = [:] + + init(byteLimit: Int) { + self.byteLimit = max(byteLimit, 0) + } + + func append(_ chunk: Data) { + guard !chunk.isEmpty else { return } + lock.lock() + defer { lock.unlock() } + guard !isFinished else { return } + + guard byteLimit > 0 else { + wasTruncated = true + return + } + if chunk.count >= byteLimit { + if !tail.isEmpty || chunk.count > byteLimit { + wasTruncated = true + } + tail = Data(chunk.suffix(byteLimit)) + return + } + + let overflow = max(tail.count + chunk.count - byteLimit, 0) + if overflow > 0 { + tail.removeFirst(overflow) + wasTruncated = true + } + tail.append(chunk) + } + + func finish() { + lock.lock() + guard !isFinished else { + lock.unlock() + return + } + isFinished = true + let continuations = waiters.values + waiters.removeAll() + lock.unlock() + + for continuation in continuations { + continuation.resume(returning: true) + } + } + + func waitUntilFinished(timeout: TimeInterval) async -> Bool { + let waiterID = UUID() + return await withCheckedContinuation { continuation in + lock.lock() + if isFinished { + lock.unlock() + continuation.resume(returning: true) + return + } + waiters[waiterID] = continuation + lock.unlock() + + let timeoutNanoseconds = UInt64(max(timeout, 0) * 1_000_000_000) + Task.detached { [weak self] in + if timeoutNanoseconds > 0 { + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + } + self?.expireWaiter(waiterID) + } + } + } + + func snapshot() -> Snapshot { + lock.lock() + let snapshot = Snapshot(bytes: tail, wasTruncated: wasTruncated) + lock.unlock() + return snapshot + } + + private func expireWaiter(_ waiterID: UUID) { + lock.lock() + let continuation = waiters.removeValue(forKey: waiterID) + lock.unlock() + continuation?.resume(returning: false) + } +} diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/CodexCLIProvider.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/CodexCLIProvider.swift index 752fe4a5d..9d118ccd7 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/CodexCLIProvider.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/CodexCLIProvider.swift @@ -999,6 +999,8 @@ final class CodexCLIProvider: AIProvider { return message case .processNotRunning: return "Codex app-server process is not running." + case .processExited: + return clientError.localizedDescription case .invalidResponse: return "Codex app-server returned an invalid response." case .jsonDecodeFailed: diff --git a/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift b/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift new file mode 100644 index 000000000..0298afbcf --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift @@ -0,0 +1,128 @@ +import Darwin +import Foundation + +/// One cancellation-independent owner for a direct child's destructive reap. +/// Callers may wait repeatedly, but only the detached observation task invokes +/// `waitpid` through `ProcessTermination.reapChildStatus`. +final class ChildProcessExitObserver: @unchecked Sendable { + enum Outcome: Equatable { + case exited(ProcessExitStatus) + case failed(ProcessTerminationError) + } + + private final class State: @unchecked Sendable { + private let lock = NSLock() + private var rootReaped = false + private var outcome: Outcome? + private var waiters: [UUID: CheckedContinuation] = [:] + + func markRootReaped() { + lock.lock() + rootReaped = true + lock.unlock() + } + + func finish(with outcome: Outcome) { + lock.lock() + guard self.outcome == nil else { + lock.unlock() + return + } + self.outcome = outcome + // An ownership failure also closes PID signaling. Another reaper may + // already have consumed the child, so a PID fallback is no longer safe. + rootReaped = true + let continuations = waiters.values + waiters.removeAll() + lock.unlock() + + for continuation in continuations { + continuation.resume(returning: outcome) + } + } + + func wait(timeout: TimeInterval?) async -> Outcome? { + let waiterID = UUID() + return await withCheckedContinuation { continuation in + lock.lock() + if let outcome { + lock.unlock() + continuation.resume(returning: outcome) + return + } + waiters[waiterID] = continuation + lock.unlock() + + guard let timeout else { return } + let timeoutNanoseconds = UInt64(max(timeout, 0) * 1_000_000_000) + Task.detached { [weak self] in + if timeoutNanoseconds > 0 { + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + } + self?.expireWaiter(waiterID) + } + } + } + + func withRootSignalingWindow(_ operation: () -> T) -> T? { + lock.lock() + defer { lock.unlock() } + guard !rootReaped, outcome == nil else { return nil } + return operation() + } + + private func expireWaiter(_ waiterID: UUID) { + lock.lock() + let continuation = waiters.removeValue(forKey: waiterID) + lock.unlock() + continuation?.resume(returning: nil) + } + } + + let pid: pid_t + private let state: State + + init(pid: pid_t) { + self.pid = pid + let state = State() + self.state = state + + Task.detached { + let outcome: Outcome + do { + let status = try await ProcessTermination.reapChildStatus( + pid: pid, + onReaped: { state.markRootReaped() } + ) + outcome = .exited(status) + } catch let error as ProcessTerminationError { + outcome = .failed(error) + } catch { + outcome = .failed(.waitFailed(error.localizedDescription)) + } + state.finish(with: outcome) + } + } + + func wait(timeout: TimeInterval? = nil) async -> Outcome? { + await state.wait(timeout: timeout) + } + + /// Signals only while this observer still owns an unreaped root PID. Holding + /// the same lock used by `onReaped` closes the lifecycle signaling window at + /// the sole-reaper boundary rather than after actor scheduling. + func signalRootProcessFamilyIfUnreaped( + processGroupID: pid_t?, + signal: Int32, + logger: (String) -> Void = { _ in } + ) -> Bool? { + state.withRootSignalingWindow { + ProcessTermination.signalProcessGroupOrPID( + pid: pid, + processGroupID: processGroupID, + signal: signal, + logger: logger + ) + } + } +} diff --git a/Sources/RepoPrompt/Infrastructure/Process/ProcessTermination.swift b/Sources/RepoPrompt/Infrastructure/Process/ProcessTermination.swift index 06b755345..5efd03fe8 100644 --- a/Sources/RepoPrompt/Infrastructure/Process/ProcessTermination.swift +++ b/Sources/RepoPrompt/Infrastructure/Process/ProcessTermination.swift @@ -41,7 +41,7 @@ enum ProcessExitStatus: Equatable { } } -enum ProcessTerminationError: Error, LocalizedError { +enum ProcessTerminationError: Error, Equatable, LocalizedError { case childOwnershipLost(pid: pid_t) case waitFailed(String) @@ -416,6 +416,49 @@ enum ProcessTermination { } } + /// Applies TERM-to-KILL policy to a child whose sole destructive reap is + /// already owned by `ChildProcessExitObserver`. No code in this path calls + /// `waitpid`; descendant cleanup starts only after observation settles. + static func terminateObservedProcessFamily( + observer: ChildProcessExitObserver, + processGroupID: pid_t?, + sigtermGrace: TimeInterval? = nil, + sigkillGrace: TimeInterval? = nil, + logger: (String) -> Void = { _ in } + ) async { + let timing = currentTiming() + let termGrace = max(sigtermGrace ?? timing.sigtermGrace, 0) + let killGrace = max(sigkillGrace ?? timing.sigkillGrace, 0) + + if await observer.wait(timeout: 0) == nil { + _ = observer.signalRootProcessFamilyIfUnreaped( + processGroupID: processGroupID, + signal: SIGTERM, + logger: logger + ) + } + + if await observer.wait(timeout: termGrace) == nil { + logger("Process \(observer.pid) did not exit after SIGTERM; sending SIGKILL") + _ = observer.signalRootProcessFamilyIfUnreaped( + processGroupID: processGroupID, + signal: SIGKILL, + logger: logger + ) + if await observer.wait(timeout: killGrace) == nil { + logger("Process \(observer.pid) has not settled after SIGKILL; awaiting the sole reaper") + _ = await observer.wait() + } + } + + await terminateProcessGroupAfterRootReap( + processGroupID: processGroupID, + sigtermGrace: termGrace, + sigkillGrace: killGrace, + logger: logger + ) + } + static func waitForTermination( pid: pid_t, processGroupID: pid_t?, diff --git a/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift b/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift index f289422b1..c5368c8be 100644 --- a/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift +++ b/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift @@ -7,6 +7,16 @@ import XCTest /// ProcessTermination authority: exited-vs-signaled semantics must survive /// alongside the historical normalized `128 + signal` mapping. final class ProcessTerminationExitStatusTests: XCTestCase { + private var temporaryDirectories: [URL] = [] + + override func tearDownWithError() throws { + for directory in temporaryDirectories { + try? FileManager.default.removeItem(at: directory) + } + temporaryDirectories.removeAll() + try super.tearDownWithError() + } + func testDecodeWaitStatusPreservesExitSignalAndFallbackSemantics() { // Raw waitpid statuses: exit code lives in the high byte, an uncaught // signal in the low 7 bits, and 0x7F marks a stopped child. @@ -88,6 +98,90 @@ final class ProcessTerminationExitStatusTests: XCTestCase { } } + func testChildProcessExitObserverSharesOneDetailedReap() async throws { + let spawned = try ProcessLauncher.spawn( + command: "/bin/sh", + arguments: ["-c", "exit 19"], + environment: [:], + workingDirectory: nil + ) + spawned.stdin?.closeFile() + let observer = ChildProcessExitObserver(pid: spawned.pid) + + let first = await observer.wait(timeout: 5) + let second = await observer.wait(timeout: 0) + XCTAssertEqual(first, .exited(.exited(code: 19))) + XCTAssertEqual(second, first) + + do { + _ = try await ProcessTermination.reapChildStatus(pid: spawned.pid) + XCTFail("The observer must remain the root child's only destructive reaper") + } catch let error as ProcessTerminationError { + XCTAssertEqual(error, .childOwnershipLost(pid: spawned.pid)) + } + } + + func testObservedTerminationEscalatesBeforeGroupOnlyCleanup() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("ProcessTerminationExitStatusTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + temporaryDirectories.append(directory) + let childPIDURL = directory.appendingPathComponent("child.pid") + let script = """ + import os + import signal + import time + + signal.signal(signal.SIGTERM, signal.SIG_IGN) + child = os.fork() + if child == 0: + signal.signal(signal.SIGTERM, signal.SIG_IGN) + while True: + time.sleep(1) + with open(\(String(reflecting: childPIDURL.path)), "w", encoding="utf-8") as handle: + handle.write(str(child)) + handle.flush() + os.fsync(handle.fileno()) + while True: + time.sleep(1) + """ + let spawned = try ProcessLauncher.spawn( + command: "/usr/bin/python3", + arguments: ["-c", script], + environment: [:], + workingDirectory: directory.path + ) + let observer = ChildProcessExitObserver(pid: spawned.pid) + let cleanup = ObservedProcessFixtureCleanup( + observer: observer, + processGroupID: spawned.processGroupID + ) + addTeardownBlock { + await cleanup.run() + } + spawned.stdin?.closeFile() + guard let childPID = await waitForPID(at: childPIDURL) else { + return XCTFail("Timed out waiting for the descendant PID fixture") + } + + await cleanup.run() + + let terminationOutcome = await observer.wait(timeout: 0) + XCTAssertEqual( + terminationOutcome, + .exited(.uncaughtSignal(signal: SIGKILL)) + ) + let descendantIsAbsent = await waitUntilProcessIsAbsent(childPID) + XCTAssertTrue(descendantIsAbsent, "SIGTERM-resistant descendant remained after group cleanup") + + do { + _ = try await ProcessTermination.reapChildStatus(pid: spawned.pid) + XCTFail("Observer-aware teardown must not perform or permit a second root reap") + } catch let error as ProcessTerminationError { + XCTAssertEqual(error, .childOwnershipLost(pid: spawned.pid)) + } + } + func testWaitForTerminationStatusReportsRealChildExitAndSignal() async throws { let exiting = try ProcessLauncher.spawn( command: "/bin/sh", @@ -120,4 +214,50 @@ final class ProcessTerminationExitStatusTests: XCTestCase { XCTAssertEqual(signalOutcome.status, .uncaughtSignal(signal: SIGKILL)) XCTAssertEqual(signalOutcome.status.normalizedExitCode, 128 + SIGKILL) } + + private func waitForPID(at url: URL, timeout: TimeInterval = 2) async -> pid_t? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let text = try? String(contentsOf: url, encoding: .utf8), + let pid = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + return pid + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return nil + } + + private func waitUntilProcessIsAbsent(_ pid: pid_t, timeout: TimeInterval = 2) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if kill(pid, 0) == -1, errno == ESRCH { + return true + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return kill(pid, 0) == -1 && errno == ESRCH + } +} + +private actor ObservedProcessFixtureCleanup { + private let observer: ChildProcessExitObserver + private let processGroupID: pid_t? + private var didRun = false + + init(observer: ChildProcessExitObserver, processGroupID: pid_t?) { + self.observer = observer + self.processGroupID = processGroupID + } + + func run() async { + guard !didRun else { return } + didRun = true + await ProcessTermination.terminateObservedProcessFamily( + observer: observer, + processGroupID: processGroupID, + sigtermGrace: 0.05, + sigkillGrace: 0.5 + ) + } } diff --git a/Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift b/Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift new file mode 100644 index 000000000..4f442f6b9 --- /dev/null +++ b/Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift @@ -0,0 +1,623 @@ +import Darwin +import Foundation +@testable import RepoPromptApp +import XCTest + +final class CodexAppServerClientProcessExitTests: XCTestCase { + private var temporaryDirectories: [URL] = [] + + override func tearDownWithError() throws { + for directory in temporaryDirectories { + try? FileManager.default.removeItem(at: directory) + } + temporaryDirectories.removeAll() + try super.tearDownWithError() + } + + func testStderrCaptureRetainsExactRawSuffixAtEveryBoundary() async { + let invalidUTF8 = Data([0x66, 0x80, 0x67]) + let scenarios: [[Data]] = [ + [], + [Data([0x01])], + [Data(repeating: 0x02, count: 8191)], + [Data(repeating: 0x03, count: 8192)], + [Data(repeating: 0x04, count: 8193)], + [Data(repeating: 0x05, count: 8190), invalidUTF8] + ] + + for chunks in scenarios { + let capture = CodexProcessStderrCapture(byteLimit: 8 * 1024) + let complete = Task { await capture.waitUntilFinished(timeout: 1) } + let allBytes = chunks.reduce(into: Data()) { $0.append($1) } + for chunk in chunks { + capture.append(chunk) + } + capture.finish() + + let didFinish = await complete.value + XCTAssertTrue(didFinish) + let snapshot = capture.snapshot() + XCTAssertEqual(snapshot.bytes, Data(allBytes.suffix(8 * 1024))) + XCTAssertEqual(snapshot.wasTruncated, allBytes.count > 8 * 1024) + } + } + + func testStartupEOFReturnsTypedExitWithSettledBoundedStderr() async throws { + let directory = try makeTemporaryDirectory() + let payload = Data(repeating: 0x41, count: 9000) + Data([0x80, 0x42]) + let stderrReleaseURL = directory.appendingPathComponent("release-stderr") + let executable = try makeEarlyExitServer( + in: directory, + stderr: payload, + termination: .exit(23), + stderrReleaseURL: stderrReleaseURL + ) + let expectedPIDEvents = ExpectedAgentPIDEventRecorder() + let registrar = CodexAppServerClient.ExpectedAgentPIDRegistrar( + register: { pid, clientName, runID in + await expectedPIDEvents.recordRegister(pid: pid, clientName: clientName, runID: runID) + }, + clear: { pid, clientName, runID in + await expectedPIDEvents.recordClear(pid: pid, clientName: clientName, runID: runID) + } + ) + let client = try await makeClient( + executable: executable, + launchDirectory: directory, + timeout: 5, + expectedAgentPIDRegistrar: registrar + ) + await client.setExpectedAgentPIDRegistration(.init(clientName: "test-client", runID: UUID())) + let startupCompletion = CompletionFlag() + let startup = Task { + do { + try await client.startIfNeeded() + await startupCompletion.markComplete() + } catch { + await startupCompletion.markComplete() + throw error + } + } + try await waitUntil(timeout: 2) { + guard case .observedProcessExit(status: .exited(code: 23)) = + await client.debugLastTransportTerminationReason() + else { + return false + } + return true + } + try await waitUntil(timeout: 1) { + await expectedPIDEvents.clearCount == 1 + } + let registrationCount = await expectedPIDEvents.registerCount + let startupCompletedAfterPIDClear = await startupCompletion.isComplete + XCTAssertEqual(registrationCount, 1) + XCTAssertFalse(startupCompletedAfterPIDClear) + + let stopCompletion = CompletionFlag() + let stop = Task { + await client.stop() + await stopCompletion.markComplete() + } + try await Task.sleep(nanoseconds: 50_000_000) + let stopReturnedBeforeSettlement = await stopCompletion.isComplete + XCTAssertFalse(stopReturnedBeforeSettlement) + await stop.value + + do { + try await startup.value + XCTFail("The early-exit fixture must fail startup") + } catch let CodexAppServerClient.ClientError.processExited(evidence) { + XCTAssertEqual(evidence.executablePath, executable.path) + XCTAssertEqual(evidence.launchDirectory, directory.path) + XCTAssertEqual(evidence.status, .exited(code: 23)) + XCTAssertEqual(evidence.stderrTail, Data(payload.suffix(8 * 1024))) + XCTAssertTrue(evidence.stderrWasTruncated) + XCTAssertTrue(evidence.stderrWasSettled) + XCTAssertTrue(evidence.stderrTail.contains(0x80)) + } catch { + XCTFail("Expected typed processExited evidence, got \(error)") + } + let activePID = await client.debugProcessID() + let activeObserver = await client.debugProcessExitObserver() + let clearCount = await expectedPIDEvents.clearCount + XCTAssertNil(activePID) + XCTAssertNil(activeObserver) + XCTAssertEqual(clearCount, 1) + } + + func testStartupSignalExitKeepsSignalSemanticsAndOmitsEmptyStderr() async throws { + let directory = try makeTemporaryDirectory() + let executable = try makeEarlyExitServer( + in: directory, + stderr: Data(), + termination: .signal(SIGKILL) + ) + let client = try await makeClient(executable: executable, launchDirectory: directory, timeout: 5) + + do { + try await client.startIfNeeded() + XCTFail("The signaled fixture must fail startup") + } catch let CodexAppServerClient.ClientError.processExited(evidence) { + XCTAssertEqual(evidence.status, .uncaughtSignal(signal: SIGKILL)) + XCTAssertTrue(evidence.stderrTail.isEmpty) + XCTAssertFalse(evidence.stderrWasTruncated) + XCTAssertTrue(evidence.stderrWasSettled) + XCTAssertFalse(CodexAppServerClient.ClientError.processExited(evidence).localizedDescription.contains("stderr")) + } catch { + XCTFail("Expected typed processExited evidence, got \(error)") + } + } + + func testListModelsRetriesTypedProcessExitOnceOnFreshProcess() async throws { + let directory = try makeTemporaryDirectory() + let attemptURL = directory.appendingPathComponent("attempt-count") + let executable = try makeExitThenModelServer(in: directory, attemptURL: attemptURL) + let client = try await makeClient(executable: executable, launchDirectory: directory, timeout: 5) + + let models = try await client.listModels() + + XCTAssertEqual(models.map(\.id), ["recovered-model"]) + XCTAssertEqual(try String(contentsOf: attemptURL, encoding: .utf8), "2") + await client.stop() + } + + func testExplicitStopWinsOverObservedTermination() async throws { + let directory = try makeTemporaryDirectory() + let recordURL = directory.appendingPathComponent("requests.jsonl") + let executable = try makePersistentServer( + in: directory, + recordURL: recordURL, + ignoredMethods: ["blocked"] + ) + let client = try await makeClient(executable: executable, launchDirectory: directory, timeout: 5) + try await client.startIfNeeded() + + let pending = Task { + try await client.request(method: "blocked", params: nil) + } + try await waitForRecordedMethod("blocked", at: recordURL) + await client.stop() + + do { + _ = try await pending.value + XCTFail("Explicit stop must fail the pending request") + } catch let error as CodexAppServerClient.ClientError { + guard case .processNotRunning = error else { + return XCTFail("Explicit stop was relabeled as \(error)") + } + } + let terminationReason = await client.debugLastTransportTerminationReason() + XCTAssertEqual(terminationReason, .explicitStop) + } + + func testTransportWriteFailureWinsOverObservedTermination() async throws { + let directory = try makeTemporaryDirectory() + let recordURL = directory.appendingPathComponent("requests.jsonl") + let executable = try makePersistentServer(in: directory, recordURL: recordURL) + let client = CodexAppServerClient(writeFrameHandler: { _, _ in + throw FDWriteError.brokenPipe(errno: EPIPE) + }) + await client.updateConfig(.init( + commandName: executable.path, + additionalPathHints: [], + requestTimeout: 5, + processLaunchDirectory: directory.path + )) + + do { + try await client.startIfNeeded() + XCTFail("The injected stdin failure must fail initialization") + } catch let CodexAppServerClient.ClientError.transportWriteFailed(_, errnoValue) { + XCTAssertEqual(errnoValue, EPIPE) + } catch { + XCTFail("stdin failure was relabeled as \(error)") + } + + let terminationReason = await client.debugLastTransportTerminationReason() + XCTAssertEqual(terminationReason, .stdinWrite(method: "initialize", errno: EPIPE)) + await client.stop() + } + + func testDecodeRecoveryExhaustionWinsOverObservedTermination() async throws { + let directory = try makeTemporaryDirectory() + let recordURL = directory.appendingPathComponent("requests.jsonl") + let executable = try makePersistentServer(in: directory, recordURL: recordURL) + let client = try await makeClient(executable: executable, launchDirectory: directory, timeout: 5) + try await client.startIfNeeded() + let generation = await client.debugTransportGeneration() + let invalidLine = Data("not-json".utf8) + + for _ in 0 ... CodexAppServerClient.debugMaxDecodeRecoveryAttemptsPerGeneration() { + await client.debugIngestRawStdoutLine(invalidLine) + } + try await waitUntil(timeout: 2) { + await !(client.debugIsProcessRunning()) + } + + let terminationReason = await client.debugLastTransportTerminationReason() + XCTAssertEqual(terminationReason, .decodeRecoveryBudgetExceeded(generation: generation)) + await client.stop() + } + + func testTimeoutPoisoningWinsOverObservedTermination() async throws { + let directory = try makeTemporaryDirectory() + let recordURL = directory.appendingPathComponent("requests.jsonl") + let executable = try makePersistentServer( + in: directory, + recordURL: recordURL, + ignoredMethods: ["thread/start"] + ) + let client = try await makeClient(executable: executable, launchDirectory: directory, timeout: 5) + try await client.startIfNeeded() + + do { + _ = try await client.request(method: "thread/start", params: [:], timeout: 0.05) + XCTFail("The ignored request must time out") + } catch let CodexAppServerClient.ClientError.requestFailed(failure) { + XCTAssertTrue(failure.message.contains("timed out")) + } catch { + XCTFail("Timeout poisoning was relabeled as \(error)") + } + + let terminationReason = await client.debugLastTransportTerminationReason() + guard case .timeout(method: "thread/start", requestID: _) = terminationReason else { + return XCTFail("Timeout did not retain lifecycle precedence") + } + await client.stop() + } + + func testStaleObservedExitCannotMutateReplacementGeneration() async throws { + let directory = try makeTemporaryDirectory() + let recordURL = directory.appendingPathComponent("requests.jsonl") + let executable = try makePersistentServer(in: directory, recordURL: recordURL) + let client = try await makeClient(executable: executable, launchDirectory: directory, timeout: 5) + try await client.startIfNeeded() + let staleGeneration = await client.debugTransportGeneration() + let staleObserverValue = await client.debugProcessExitObserver() + let staleObserver = try XCTUnwrap(staleObserverValue) + + await client.stop() + try await client.startIfNeeded() + let replacementGeneration = await client.debugTransportGeneration() + let replacementPID = await client.debugProcessID() + let replacementObserverValue = await client.debugProcessExitObserver() + let replacementObserver = try XCTUnwrap(replacementObserverValue) + + await client.debugDeliverObservedProcessExit( + .exited(.exited(code: 99)), + observer: staleObserver, + generation: staleGeneration + ) + + let currentGeneration = await client.debugTransportGeneration() + let currentPID = await client.debugProcessID() + let currentObserver = await client.debugProcessExitObserver() + let isRunning = await client.debugIsProcessRunning() + let terminationReason = await client.debugLastTransportTerminationReason() + XCTAssertEqual(currentGeneration, replacementGeneration) + XCTAssertEqual(currentPID, replacementPID) + XCTAssertTrue(currentObserver === replacementObserver) + XCTAssertTrue(isRunning) + XCTAssertNil(terminationReason) + await client.stop() + } + + func testStopDuringRestartPreparationPreventsSpawnAfterReturn() async throws { + let directory = try makeTemporaryDirectory() + let recordURL = directory.appendingPathComponent("requests.jsonl") + let spawnCountURL = directory.appendingPathComponent("spawn-count") + let executable = try makePersistentServer( + in: directory, + recordURL: recordURL, + spawnCountURL: spawnCountURL + ) + let spawnPreparation = ProcessSpawnPreparationGate(blockedInvocation: 2) + let client = try await makeClient( + executable: executable, + launchDirectory: directory, + timeout: 5, + processSpawnPreparation: { await spawnPreparation.prepare() } + ) + try await client.startIfNeeded() + try await waitUntil(timeout: 2) { + (try? String(contentsOf: spawnCountURL, encoding: .utf8)) == "1" + } + await client.stop() + + let restart = Task { + try await client.startIfNeeded() + } + try await waitUntil(timeout: 2) { + await spawnPreparation.isBlocked + } + await client.stop() + await spawnPreparation.release() + + do { + try await restart.value + XCTFail("The stopped restart must not reach process spawn") + } catch is CancellationError { + // Expected: stop revokes startup authority before returning. + } catch { + XCTFail("Expected restart cancellation, got \(error)") + } + XCTAssertEqual(try String(contentsOf: spawnCountURL, encoding: .utf8), "1") + let isRunning = await client.debugIsProcessRunning() + let processObserver = await client.debugProcessExitObserver() + XCTAssertFalse(isRunning) + XCTAssertNil(processObserver) + await client.stop() + } + + func testDeinitLeavesReapOwnershipWithObserver() async throws { + let directory = try makeTemporaryDirectory() + let recordURL = directory.appendingPathComponent("requests.jsonl") + let executable = try makePersistentServer(in: directory, recordURL: recordURL) + var client: CodexAppServerClient? = try await makeClient( + executable: executable, + launchDirectory: directory, + timeout: 5 + ) + try await client?.startIfNeeded() + let pidValue = await client?.debugProcessID() + let observerValue = await client?.debugProcessExitObserver() + let pid = try XCTUnwrap(pidValue) + let observer = try XCTUnwrap(observerValue) + client = nil + + guard let outcome = await observer.wait(timeout: 3) else { + return XCTFail("The cancellation-independent observer did not reap after client deinit") + } + guard case .exited = outcome else { + return XCTFail("The sole observer failed to reap after client deinit: \(outcome)") + } + + var status: Int32 = 0 + errno = 0 + XCTAssertEqual(waitpid(pid, &status, WNOHANG), -1) + XCTAssertEqual(errno, ECHILD) + } + + private enum EarlyTermination { + case exit(Int32) + case signal(Int32) + } + + private func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexAppServerClientProcessExitTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + temporaryDirectories.append(directory) + return directory + } + + private func makeClient( + executable: URL, + launchDirectory: URL, + timeout: TimeInterval, + processSpawnPreparation: @escaping @Sendable () async throws -> Void = {}, + expectedAgentPIDRegistrar: CodexAppServerClient.ExpectedAgentPIDRegistrar = .serverNetworkManager + ) async throws -> CodexAppServerClient { + let client = CodexAppServerClient( + processSpawnPreparation: processSpawnPreparation, + expectedAgentPIDRegistrar: expectedAgentPIDRegistrar + ) + await client.updateConfig(.init( + commandName: executable.path, + additionalPathHints: [], + requestTimeout: timeout, + processLaunchDirectory: launchDirectory.path + )) + return client + } + + private func makeEarlyExitServer( + in directory: URL, + stderr: Data, + termination: EarlyTermination, + stderrReleaseURL: URL? = nil + ) throws -> URL { + let executable = directory.appendingPathComponent("early-exit-codex") + let terminationSource = switch termination { + case let .exit(code): + "os._exit(\(code))" + case let .signal(signal): + "os.kill(os.getpid(), \(signal))" + } + let releasePath = stderrReleaseURL?.path + let script = """ + #!/usr/bin/env python3 + import base64 + import os + import signal + import sys + import time + + sys.stdin.readline() + os.write(2, base64.b64decode(\(String(reflecting: stderr.base64EncodedString())))) + release_path = \(releasePath.map(String.init(reflecting:)) ?? "None") + if release_path is not None: + holder = os.fork() + if holder == 0: + os.close(0) + os.close(1) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + while not os.path.exists(release_path): + time.sleep(0.005) + os.close(2) + os._exit(0) + os.close(1) + \(terminationSource) + """ + return try writeExecutable(script, to: executable) + } + + private func makeExitThenModelServer( + in directory: URL, + attemptURL: URL + ) throws -> URL { + let executable = directory.appendingPathComponent("exit-then-model-codex") + let script = """ + #!/usr/bin/env python3 + import json + import os + import sys + + attempt_path = \(String(reflecting: attemptURL.path)) + try: + with open(attempt_path, "r", encoding="utf-8") as handle: + attempt = int(handle.read()) + except FileNotFoundError: + attempt = 0 + attempt += 1 + with open(attempt_path, "w", encoding="utf-8") as handle: + handle.write(str(attempt)) + + for line in sys.stdin: + request = json.loads(line) + method = request.get("method") + if method == "model/list" and attempt == 1: + os.close(1) + os._exit(17) + if "id" not in request: + continue + result = {} + if method == "model/list": + result = { + "data": [{"id": "recovered-model"}], + "nextCursor": None, + } + print(json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": result}), flush=True) + """ + return try writeExecutable(script, to: executable) + } + + private func makePersistentServer( + in directory: URL, + recordURL: URL, + spawnCountURL: URL? = nil, + ignoredMethods: Set = [] + ) throws -> URL { + let executable = directory.appendingPathComponent("persistent-codex") + let script = """ + #!/usr/bin/env python3 + import json + import os + import sys + + record_path = \(String(reflecting: recordURL.path)) + spawn_count_path = \(spawnCountURL.map(\.path).map(String.init(reflecting:)) ?? "None") + ignored = set(\(String(reflecting: Array(ignoredMethods).sorted()))) + + if spawn_count_path is not None: + try: + with open(spawn_count_path, "r", encoding="utf-8") as handle: + spawn_count = int(handle.read()) + except FileNotFoundError: + spawn_count = 0 + with open(spawn_count_path, "w", encoding="utf-8") as handle: + handle.write(str(spawn_count + 1)) + handle.flush() + os.fsync(handle.fileno()) + + for line in sys.stdin: + request = json.loads(line) + method = request.get("method") + with open(record_path, "a", encoding="utf-8") as handle: + handle.write(json.dumps({"method": method}) + "\\n") + handle.flush() + if "id" in request and method not in ignored: + print(json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": {}}), flush=True) + """ + return try writeExecutable(script, to: executable) + } + + private func writeExecutable(_ script: String, to url: URL) throws -> URL { + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url + } + + private func waitForRecordedMethod( + _ method: String, + at recordURL: URL, + timeout: TimeInterval = 2 + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let data = try? Data(contentsOf: recordURL), + let text = String(data: data, encoding: .utf8), + text.split(whereSeparator: \.isNewline).contains(where: { line in + guard let data = String(line).data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return false + } + return object["method"] as? String == method + }) + { + return + } + try await Task.sleep(nanoseconds: 10_000_000) + } + XCTFail("Timed out waiting for \(method)") + } + + private func waitUntil( + timeout: TimeInterval, + condition: @escaping () async -> Bool + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await condition() { return } + try await Task.sleep(nanoseconds: 10_000_000) + } + XCTFail("Timed out waiting for condition") + } +} + +private actor CompletionFlag { + private(set) var isComplete = false + + func markComplete() { + isComplete = true + } +} + +private actor ProcessSpawnPreparationGate { + private let blockedInvocation: Int + private var invocationCount = 0 + private var continuation: CheckedContinuation? + private(set) var isBlocked = false + + init(blockedInvocation: Int) { + self.blockedInvocation = blockedInvocation + } + + func prepare() async { + invocationCount += 1 + guard invocationCount == blockedInvocation else { return } + isBlocked = true + await withCheckedContinuation { continuation = $0 } + } + + func release() { + continuation?.resume() + continuation = nil + isBlocked = false + } +} + +private actor ExpectedAgentPIDEventRecorder { + private(set) var registerCount = 0 + private(set) var clearCount = 0 + + func recordRegister(pid _: pid_t, clientName _: String, runID _: UUID) { + registerCount += 1 + } + + func recordClear(pid _: pid_t, clientName _: String, runID _: UUID) { + clearCount += 1 + } +} From 8dfb63f330d79561914639f83018cba8d1aebb45 Mon Sep 17 00:00:00 2001 From: MP Date: Thu, 16 Jul 2026 07:22:41 +0800 Subject: [PATCH 03/21] test: cover Codex worktree path separation --- .../Fixtures/test-suite-contract-ledger.tsv | 1 + ...tiveSessionControllerGoalConfigTests.swift | 135 ++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index c6a3855ec..b2768b0fa 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -1185,6 +1185,7 @@ root/RepoPromptTests.CodexNativeSessionControllerGoalConfigTests/testResumeRequi root/RepoPromptTests.CodexNativeSessionControllerGoalConfigTests/testResumeWithoutPathSendsRequiredThreadIDOnly root Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift RepoPromptTests.CodexNativeSessionControllerGoalConfigTests testResumeWithoutPathSendsRequiredThreadIDOnly AgentMode codex.app_server.request_conformance initialize,resume,json_rpc protocol_contract root_swiftpm routine 1 Request frame shape or compatibility error text matches the modern app-server contract. Malformed initialization or resume requests could prevent Codex sessions from starting. 0.244000 test_case retain 0 Codex app-server modernization Items 1–3 integration coverage. root/RepoPromptTests.CodexNativeSessionControllerGoalConfigTests/testSafeManagedMCPOverridesSuppressThirdPartyServers root Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift RepoPromptTests.CodexNativeSessionControllerGoalConfigTests testSafeManagedMCPOverridesSuppressThirdPartyServers AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 60 root/RepoPromptTests.CodexNativeSessionControllerGoalConfigTests/testUnrelatedRequestFailureDoesNotAddCLIUpdateHint root Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift RepoPromptTests.CodexNativeSessionControllerGoalConfigTests testUnrelatedRequestFailureDoesNotAddCLIUpdateHint AgentMode codex.app_server.request_conformance initialize,resume,json_rpc protocol_contract root_swiftpm routine 1 Request frame shape or compatibility error text matches the modern app-server contract. Malformed initialization or resume requests could prevent Codex sessions from starting. 0.000000 test_case retain 0 Codex app-server modernization Items 1–3 integration coverage. +root/RepoPromptTests.CodexNativeSessionControllerGoalConfigTests/testWorktreePathSeparationPreservesStartResumeTurnAndWorkspaceWriteProtocol root Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift RepoPromptTests.CodexNativeSessionControllerGoalConfigTests testWorktreePathSeparationPreservesStartResumeTurnAndWorkspaceWriteProtocol AgentMode codex.app_server.worktree_path_separation process_launch_cwd,thread_start,thread_resume,turn_start,workspace_write protocol_contract root_swiftpm routine 2 fake_codex_app_server A fake app-server records the logical-root process cwd while fresh and resumed threads, turns, and workspace-write roots carry the distinct worktree root with their protocol results intact. Recoupling process and execution directories could reintroduce worktree-only startup failures or run Codex requests and writes against the logical checkout. subprocess test_case retain 0 Cross-layer worktree path-separation coverage for fresh and resumed sessions. root/RepoPromptTests.CodexNativeSessionControllerInterruptTests/testActiveTurnMismatchParserMatrix root Tests/RepoPromptTests/AI/CodexNativeSessionControllerInterruptTests.swift RepoPromptTests.CodexNativeSessionControllerInterruptTests testActiveTurnMismatchParserMatrix AI unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 5 root/RepoPromptTests.CodexNativeSessionControllerInterruptTests/testResolvedInterruptTurnIDMatrix root Tests/RepoPromptTests/AI/CodexNativeSessionControllerInterruptTests.swift RepoPromptTests.CodexNativeSessionControllerInterruptTests testResolvedInterruptTurnIDMatrix AI unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 22 root/RepoPromptTests.CodexNativeSessionControllerTurnDispatchTests/testCancellationReconciliationInterruptsUniqueSnapshotTurnWithoutPromotingIt root Tests/RepoPromptTests/AI/CodexNativeSessionControllerTurnDispatchTests.swift RepoPromptTests.CodexNativeSessionControllerTurnDispatchTests testCancellationReconciliationInterruptsUniqueSnapshotTurnWithoutPromotingIt AI unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000500 unreviewed retain_pending_review 0 initial census source line 229 diff --git a/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift b/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift index f50fc6e70..d627c1881 100644 --- a/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift +++ b/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift @@ -267,6 +267,111 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { XCTAssertEqual(reasoningSummaryTerminationReason, .explicitStop) } + func testWorktreePathSeparationPreservesStartResumeTurnAndWorkspaceWriteProtocol() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexNativeSessionControllerGoalConfigTests-\(UUID().uuidString)", isDirectory: true) + let logicalRoot = directory.appendingPathComponent("logical-root", isDirectory: true) + let worktreeRoot = directory.appendingPathComponent("worktree-root", isDirectory: true) + let initialLaunchSentinel = directory.appendingPathComponent("initial-launch-sentinel", isDirectory: true) + try FileManager.default.createDirectory(at: logicalRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: worktreeRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: initialLaunchSentinel, withIntermediateDirectories: true) + temporaryDirectories.append(directory) + + let recordURL = directory.appendingPathComponent("requests.jsonl") + let executableURL = try makeFakeCodexAppServer(in: directory, recordURL: recordURL) + let workspacePaths = CodexRuntimeWorkspacePaths.worktreeBound( + logicalRootPath: logicalRoot.path, + validatedWorktreeRootPath: worktreeRoot.path + ) + let options = CodexNativeSessionController.Options.agentModeDefault( + forceExperimentalSteering: false, + approvalPolicyProvider: { .never }, + sandboxModeProvider: { .workspaceWrite }, + approvalReviewerProvider: { .user } + ) + + let startController = await makeController( + executableURL: executableURL, + initialProcessLaunchDirectory: initialLaunchSentinel.path, + workspacePaths: workspacePaths, + options: options + ) + addTeardownBlock { + await startController.shutdown() + } + let started = try await startController.startOrResume(existing: nil, baseInstructions: "Agent") + let startReceipt = try await startController.startUserTurn( + text: "fresh turn", + images: [], + model: "fresh-model", + reasoningEffort: "high", + serviceTier: "fast" + ) + await startController.shutdown() + + XCTAssertEqual(started.conversationID, "fresh-thread") + XCTAssertEqual(startReceipt.provisionalSubmissionID, "turn-1") + + let resumeController = await makeController( + executableURL: executableURL, + initialProcessLaunchDirectory: initialLaunchSentinel.path, + workspacePaths: workspacePaths, + options: options + ) + addTeardownBlock { + await resumeController.shutdown() + } + let resumed = try await resumeController.startOrResume( + existing: .init( + conversationID: "existing-thread", + rolloutPath: "/tmp/existing-thread.jsonl", + model: nil, + reasoningEffort: nil + ), + baseInstructions: "Agent" + ) + let resumeReceipt = try await resumeController.startUserTurn( + text: "resumed turn", + images: [], + model: "resume-model", + reasoningEffort: "medium", + serviceTier: nil + ) + await resumeController.shutdown() + + XCTAssertEqual(resumed.conversationID, "existing-thread") + XCTAssertEqual(resumeReceipt.provisionalSubmissionID, "turn-1") + + let processRecords = try recordedRequests(for: "__process_args", at: recordURL) + XCTAssertEqual(processRecords.count, 2) + XCTAssertEqual( + processRecords.compactMap { $0["cwd"] as? String }.map(resolvedPath), + [resolvedPath(logicalRoot.path), resolvedPath(logicalRoot.path)] + ) + + let startParams = try recordedParams(for: "thread/start", at: recordURL) + XCTAssertEqual(startParams["cwd"] as? String, worktreeRoot.path) + let resumeParams = try recordedParams(for: "thread/resume", at: recordURL) + XCTAssertEqual(resumeParams["cwd"] as? String, worktreeRoot.path) + XCTAssertEqual(resumeParams["threadId"] as? String, "existing-thread") + XCTAssertEqual(resumeParams["path"] as? String, "/tmp/existing-thread.jsonl") + + let turnParams = try recordedRequests(for: "turn/start", at: recordURL) + .map { try XCTUnwrap($0["params"] as? [String: Any]) } + XCTAssertEqual(turnParams.count, 2) + XCTAssertEqual(turnParams.compactMap { $0["threadId"] as? String }, ["fresh-thread", "existing-thread"]) + XCTAssertEqual(turnParams.compactMap { $0["cwd"] as? String }, [worktreeRoot.path, worktreeRoot.path]) + XCTAssertEqual(turnParams.compactMap { $0["model"] as? String }, ["fresh-model", "resume-model"]) + XCTAssertEqual(turnParams.compactMap { $0["effort"] as? String }, ["high", "medium"]) + for params in turnParams { + let sandbox = try XCTUnwrap(params["sandboxPolicy"] as? [String: Any]) + XCTAssertEqual(sandbox["type"] as? String, "workspaceWrite") + XCTAssertEqual(sandbox["networkAccess"] as? Bool, true) + XCTAssertEqual(sandbox["writableRoots"] as? [String], [worktreeRoot.path]) + } + } + func testNativeSessionControllerDefaultOptionsOmitProcessReasoningSummaryOverride() async throws { let options = CodexNativeSessionController.Options( requestTimeout: 5, @@ -567,6 +672,8 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { respond(request["id"], {"thread": {"id": "fresh-thread", "status": "idle", "turns": []}}) elif method == "thread/resume": respond(request["id"], {"thread": {"id": params.get("threadId", "resumed-thread"), "status": "idle", "turns": []}}) + elif method == "turn/start": + respond(request["id"], {"turn": {"id": "turn-1"}}) else: respond(request["id"], {}) """ @@ -575,6 +682,34 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { return scriptURL } + private func makeController( + executableURL: URL, + initialProcessLaunchDirectory: String?, + workspacePaths: CodexRuntimeWorkspacePaths, + options: CodexNativeSessionController.Options + ) async -> CodexNativeSessionController { + let client = CodexAppServerClient() + await client.updateConfig(.init( + commandName: executableURL.path, + additionalPathHints: [], + requestTimeout: 5, + processLaunchDirectory: initialProcessLaunchDirectory + )) + return CodexNativeSessionController( + client: client, + runID: UUID(), + tabID: UUID(), + windowID: 0, + workspacePaths: workspacePaths, + options: options, + clientShutdownBehavior: .stopOnShutdown + ) + } + + private func resolvedPath(_ path: String) -> String { + URL(fileURLWithPath: path).resolvingSymlinksInPath().path + } + private func recordedParams(for method: String, at recordURL: URL) throws -> [String: Any] { let request = try recordedRequest(for: method, at: recordURL) return try XCTUnwrap(request["params"] as? [String: Any]) From 97f933988a44915c75b1ef2f90b43533201b4187 Mon Sep 17 00:00:00 2001 From: MP Date: Thu, 16 Jul 2026 08:34:55 +0800 Subject: [PATCH 04/21] fix: scope worktree selectors to declared repo --- .../Fixtures/test-suite-contract-ledger.tsv | 3 + .../AgentMCPStartWorktreeCoordinator.swift | 53 +++++- .../MCPAgentControlToolProvider.swift | 4 +- .../MCP/AgentRunWorktreeStartTests.swift | 161 ++++++++++++++++++ .../MCP/ToolCatalogSnapshotTests.swift | 4 +- 5 files changed, 212 insertions(+), 13 deletions(-) diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index b2768b0fa..37b924b2a 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -382,6 +382,9 @@ root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentRunStartTreatsRoutedPar root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentRunStartInheritsRoutedWorktreeFromUIParentWithoutMCPControlContext root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentRunStartInheritsRoutedWorktreeFromUIParentWithoutMCPControlContext MCP agent.start.worktree-inheritance.ui_parent_nil_control_context routed_source,ui_parent,nil_control_context,default_inheritance,effective_path behavioral_regression root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture A routed child start from a UI-originated hydrated parent with persisted worktree bindings and no MCP control context inherits the binding in the start response and effective workspace path. UI-launched worktree-bound parent sessions could be rejected during routed child-start reconciliation because only MCP-controlled parents were accepted. 0.616000 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Issue #383 routed worktree inheritance regression for UI-launched parents. root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentRunStartInheritsRoutedWorktreeFromFormerMCPParentWithoutControlContext root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentRunStartInheritsRoutedWorktreeFromFormerMCPParentWithoutControlContext MCP agent.start.worktree-inheritance.former_mcp_parent routed_source,sticky_mcp_origin,nil_control_context,default_inheritance,effective_path behavioral_regression root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture A routed child start from a hydrated parent with persisted worktree bindings, sticky isMCPOriginated=true, and no live MCP control context inherits the binding in the start response and effective workspace path. A former MCP-created parent later driven from the UI could permanently fail inherited child starts with an unavailable-control-context error despite valid persisted bindings. 0.750000 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Issue #383 QA follow-up regression for sticky MCP-origin sessions after control context release. root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartExplicitWorktreeIDOverridesInheritedBindingAcrossRunExploreAndExplicitTab root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentStartExplicitWorktreeIDOverridesInheritedBindingAcrossRunExploreAndExplicitTab MCP agent.start.explicit-worktree-override existing_worktree_id,created_worktree,inherit_true,inherit_false,explore,explicit_tab behavioral_regression root_swiftpm routine 5 AgentRunWorktreeStartLifecycleFixture Explicit existing-worktree and app-created Agent Run routes contain only the requested binding regardless of inherit_worktree, Explore preserves its explicit binding, explicit-tab routing also suppresses the captured parent binding without creating a parent relationship, and spawned children retain their expected parent relationships. Automatic or frozen-source inheritance could leak a parent checkout alongside or instead of an explicitly selected or created child worktree on Run, Explore, or explicit-tab routes. 3.177000 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture consolidated_replacement 0 Renamed from root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartExplicitWorktreeIDOverridesInheritedBindingAcrossRunAndExplore; retains replacements root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentRunStartExplicitWorktreeIDOverridesInheritanceSetting and root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentExploreStartExplicitWorktreeIDOverridesInheritedBindingBeforeProviderStart; strengthened with explicit-tab override precedence. +root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartWorktreeSelectorRejectsSecondaryGitRepoWhenPrimaryRootIsNonGit root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentStartWorktreeSelectorRejectsSecondaryGitRepoWhenPrimaryRootIsNonGit MCP agent.start.worktree-selector.non-git-primary-rejection non_git_primary,secondary_git,worktree_id,fail_closed,no_target_mutation behavioral_regression root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture A shorthand worktree ID targeting a secondary Git repository is rejected by the primary-root runtime boundary when the declared primary workspace root is non-Git, without creating a target tab. Fallback repository resolution could pair a secondary Git checkout with the non-Git primary logical root and bypass the primary-only provider runtime restriction. suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Primary-root fail-closed regression for implicit worktree repository selection. +root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartWorktreeSelectorsDefaultToWorkspacePrimaryRepoWhenRootsLoadedOutOfOrder root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentStartWorktreeSelectorsDefaultToWorkspacePrimaryRepoWhenRootsLoadedOutOfOrder MCP agent.start.worktree-selector.primary-root-default multi_root,inverse_root_order,primary_workspace,worktree_id,branch_selector,implicit_repo_root,explicit_repo_root behavioral_regression root_swiftpm routine 5 AgentRunWorktreeStartLifecycleFixture A deterministic inverse-order root-ref seam selects the declared primary root, then worktree ID and branch selectors bind that primary repository and logical root for both implicit and explicit primary-root selection. Using file-index load order as the implicit repository, or mishandling an explicit primary selector, could reject a valid primary-root worktree before provider startup or associate it with the wrong logical root. suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Multi-root shorthand selector regression for issue #554. +root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartWorktreeSelectorsRejectCrossRepositoryMatchesForImplicitAndExplicitPrimaryRoot root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentStartWorktreeSelectorsRejectCrossRepositoryMatchesForImplicitAndExplicitPrimaryRoot MCP agent.start.worktree-selector.repository-authority multi_root,worktree_id,branch_selector,implicit_repo_root,explicit_repo_root,cross_repository,fail_closed,no_target_mutation behavioral_regression root_swiftpm routine 4 AgentRunWorktreeStartLifecycleFixture Worktree ID and branch selectors targeting a secondary repository are rejected for both implicit and explicit primary-root selection, leaving tab and session counts unchanged. A globally unique foreign worktree selector could resolve across workspace repositories and be persisted under the selected primary logical root. suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Existing-worktree selector repository-authority regression for issue #554. root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionMaterializesSessionWorktreeWithoutCodemapWork root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testBindingTransitionMaterializesSessionWorktreeWithoutCodemapWork MCP agent.worktree.binding_transition.zero_codemap_materialization initial_binding,watcher_ownership,idempotent_materialization,zero_codemap_work,last_release_cleanup deterministic_integration root_swiftpm routine 4 AgentRunWorktreeStartLifecycleFixture Initial binding materializes one watched physical root with zero legacy scans, modern demands, freezes, or initialization tasks; repeated projection reuses the owner/root without recrawl; unbind releases the final claim. Worktree binding could eagerly start codemap work, duplicate catalog ownership, or leak the final root claim. 0.064500 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Milestone D replaces eager session-worktree codemap initialization with neutral materialization and zero-work assertions. root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionValidatesAndMaterializesChangedSecondaryBindingWithoutRestartingPrimaryIdentity root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testBindingTransitionValidatesAndMaterializesChangedSecondaryBindingWithoutRestartingPrimaryIdentity MCP agent.worktree.binding-transition.secondary behavioral_regression root_swiftpm routine 2 AgentRunWorktreeStartLifecycleFixture Unavailable secondary binding rejects atomically; valid changed secondary materializes both physical roots without restarting provider or Codex identity high 0.804500 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture consolidated_replacement 0 replaces root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionMaterializesChangedSecondaryBindingWithoutRestartingUnchangedPrimaryIdentity; root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionRejectsUnavailableSecondaryBindingEvenWhenPrimaryIdentityIsUnchanged root/RepoPromptTests.AgentRunWorktreeStartTests/testCanonicalAgentRunReviewSourceStagesBindsToFreshWorktreeChildAndCleansUp root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCanonicalAgentRunReviewSourceStagesBindsToFreshWorktreeChildAndCleansUp MCP agent.oracle_review.canonical_run_binding launch_snapshot,exact_run,cleanup deterministic_lifecycle_integration root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture Canonical source packaging stages for an exact fresh worktree child run with an independent target binding and disappears on control deactivation. A stale or sibling child run could consume source-owned review packaging. 0.107000 suite_owned_temp_roots;awaited_window_teardown WindowStatesManager AgentRunWorktreeStartLifecycleFixture retain 0 Issue #264 exact-ID ledger reconciliation diff --git a/Sources/RepoPrompt/Infrastructure/MCP/Agent/AgentMCPStartWorktreeCoordinator.swift b/Sources/RepoPrompt/Infrastructure/MCP/Agent/AgentMCPStartWorktreeCoordinator.swift index be8a6126f..3dc029a43 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/Agent/AgentMCPStartWorktreeCoordinator.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/Agent/AgentMCPStartWorktreeCoordinator.swift @@ -53,11 +53,15 @@ struct AgentMCPStartWorktreeCoordinator { private struct RepositoryContext { let repo: GitRepoDescriptor - let allRepos: [GitRepoDescriptor] let visibleRoots: [WorkspaceRootRef] let logicalRoot: WorkspaceRootRef } + private struct RepositoryCandidate { + let repo: GitRepoDescriptor + let logicalRoot: WorkspaceRootRef + } + let operationName: String let vcsService: VCSService let gitTargetResolver: GitRepoTargetResolver @@ -183,10 +187,12 @@ struct AgentMCPStartWorktreeCoordinator { throw MCPError.internalError("\(operationName) worktree preparation reached an unexpected empty worktree mode.") case let .existing(selector): do { + // Repository selection is the authority boundary for the resulting logical-root binding. + // Searching sibling workspace repositories could bind a foreign worktree under that root. worktree = try await gitTargetResolver.resolveWorktree( selector: selector, repo: context.repo, - allRepos: context.allRepos + allRepos: [context.repo] ) initializationReceipt = nil initializationFallbackReason = nil @@ -416,19 +422,24 @@ struct AgentMCPStartWorktreeCoordinator { ) async throws -> RepositoryContext { let store = targetWindow.promptManager.workspaceFileContextStore let visibleRoots = await store.rootRefs(scope: .visibleWorkspace) - var repos: [GitRepoDescriptor] = [] + let discoveryRoots = Self.repositoryDiscoveryRoots( + primaryRoot: targetWindow.workspaceManager.activeWorkspace?.repoPaths.first, + visibleRoots: visibleRoots + ) + var candidates: [RepositoryCandidate] = [] var seen = Set() - for root in visibleRoots { + for root in discoveryRoots { if let resolved = await vcsService.resolveRepo(from: URL(fileURLWithPath: root.standardizedFullPath)) { let descriptor = GitRepoDescriptor(rootURL: resolved.rootURL) if seen.insert(descriptor.rootPath.lowercased()).inserted { - repos.append(descriptor) + candidates.append(RepositoryCandidate(repo: descriptor, logicalRoot: root)) } } } - guard let defaultRepo = repos.first else { + guard let defaultCandidate = candidates.first else { throw MCPError.invalidParams("No Git repository found in loaded roots for \(operationName) worktree binding.") } + let repos = candidates.map(\.repo) let repo: GitRepoDescriptor var explicitLogicalRoot: WorkspaceRootRef? if let rawRepoRoot = request.repoRoot { @@ -440,13 +451,14 @@ struct AgentMCPStartWorktreeCoordinator { rawRepoRoot, allRepos: repos, visibleRoots: visibleRoots, - defaultRepo: defaultRepo + defaultRepo: defaultCandidate.repo ) } catch let error as GitRepoTargetResolverError { throw MCPError.invalidParams(error.message) } } else { - repo = defaultRepo + repo = defaultCandidate.repo + explicitLogicalRoot = defaultCandidate.logicalRoot } let logicalRoot = try await logicalRoot( for: repo, @@ -455,7 +467,6 @@ struct AgentMCPStartWorktreeCoordinator { ) return RepositoryContext( repo: repo, - allRepos: repos, visibleRoots: visibleRoots, logicalRoot: logicalRoot ) @@ -709,6 +720,30 @@ struct AgentMCPStartWorktreeCoordinator { } } + /// Provider startup treats the declared workspace root as primary even when file-root load order differs. + /// Preserve sibling order so only the implicit default changes. + static func repositoryDiscoveryRoots( + primaryRoot: String?, + visibleRoots: [WorkspaceRootRef] + ) -> [WorkspaceRootRef] { + guard let primaryRoot else { + return visibleRoots + } + let primaryPath = ((primaryRoot as NSString).expandingTildeInPath as NSString).standardizingPath + guard let primaryIndex = visibleRoots.firstIndex(where: { + ($0.standardizedFullPath as NSString).standardizingPath == primaryPath + }) else { + return visibleRoots + } + guard primaryIndex != visibleRoots.startIndex else { + return visibleRoots + } + var ordered = visibleRoots + let primary = ordered.remove(at: primaryIndex) + ordered.insert(primary, at: ordered.startIndex) + return ordered + } + private func explicitWorktreePath(from value: Value?) throws -> URL? { guard let raw = AgentMCPToolHelpers.normalizedString(value) else { return nil } let expanded = (raw as NSString).expandingTildeInPath diff --git a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPAgentControlToolProvider.swift b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPAgentControlToolProvider.swift index 86780fcb4..44831e413 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPAgentControlToolProvider.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPAgentControlToolProvider.swift @@ -63,7 +63,7 @@ final class MCPAgentControlToolProvider: MCPWindowToolProviding { "worktree_id": .string(description: "[start] Durable worktree ID to bind before provider startup. Mutually exclusive with worktree and worktree_create."), "worktree_create": .boolean(description: "[start] Create an app-managed Git worktree, bind it to the new session, materialize its hidden root, then start the provider. Mutually exclusive with worktree/worktree_id."), "inherit_worktree": .boolean(description: "[start] When started from an Agent Mode run, inherit the source session's worktree bindings before provider startup. Default true. Set false to keep parent session threading but skip worktree inheritance; explicit worktree/worktree_id/worktree_create args still bind the requested worktree."), - "worktree_repo_root": .string(description: "[start] Repo/logical root selector for worktree resolution or creation. Defaults to the first loaded Git repo."), + "worktree_repo_root": .string(description: "[start] Repo/logical root selector for worktree resolution or creation. Defaults to the declared primary workspace root."), "worktree_branch": .string(description: "[start + worktree_create] Optional branch name for the new worktree. Defaults to an rp/agent/-... branch."), "worktree_base_ref": .string(description: "[start + worktree_create] Optional base ref/commit for the new worktree."), "worktree_path": .string(description: "[start + worktree_create] Optional explicit absolute path (or ~/...). External paths require allow_external_worktree_path=true."), @@ -98,7 +98,7 @@ final class MCPAgentControlToolProvider: MCPWindowToolProviding { "worktree_id": .string(description: "[start] Durable worktree ID to bind before provider startup. Mutually exclusive with worktree and worktree_create."), "worktree_create": .boolean(description: "[start] Create an app-managed Git worktree, bind it to the new session, materialize its hidden root, then start the provider. Mutually exclusive with worktree/worktree_id."), "inherit_worktree": .boolean(description: "[start] When started from an Agent Mode run, inherit the source session's worktree bindings before provider startup. Default true. Set false to keep parent session threading but skip worktree inheritance. Explicit worktree/worktree_id/worktree_create args take precedence, suppress parent inheritance, and bind only the requested worktree."), - "worktree_repo_root": .string(description: "[start] Repo/logical root selector for worktree resolution or creation. Defaults to the first loaded Git repo."), + "worktree_repo_root": .string(description: "[start] Repo/logical root selector for worktree resolution or creation. Defaults to the declared primary workspace root."), "worktree_branch": .string(description: "[start + worktree_create] Optional branch name for the new worktree. Defaults to an rp/agent/-... branch."), "worktree_base_ref": .string(description: "[start + worktree_create] Optional base ref/commit for the new worktree."), "worktree_path": .string(description: "[start + worktree_create] Optional explicit absolute path (or ~/...). External paths require allow_external_worktree_path=true."), diff --git a/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift b/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift index d54af0e13..a0c46f528 100644 --- a/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift +++ b/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift @@ -2486,6 +2486,167 @@ final class AgentRunWorktreeStartTests: AgentRunWorktreeStartGitSeedTestCase { XCTAssertEqual(exploreChild.worktreeBindings, observation.bindings) } + func testAgentStartWorktreeSelectorsDefaultToWorkspacePrimaryRepoWhenRootsLoadedOutOfOrder() async throws { + let primaryFixture = try makeGitFixture() + let secondaryFixture = try makeGitFixture() + let branch = "feature/primary-selector-\(primaryFixture.suffix)" + let primaryWorktree = primaryFixture.sandbox.appendingPathComponent( + "primary-selector-worktree", + isDirectory: true + ) + try runGit( + ["worktree", "add", "-b", branch, primaryWorktree.path, "HEAD"], + cwd: primaryFixture.repo + ) + let expectedHead = try runGitOutput(["rev-parse", "HEAD"], cwd: primaryFixture.repo) + let descriptor = try await GitWorktreeTestSupport.waitForStableDescriptor( + repo: primaryFixture.repo, + path: primaryWorktree, + expectedBranch: branch, + expectedHead: expectedHead, + listDescriptors: { try await VCSService.shared.listGitWorktrees(at: primaryFixture.repo) } + ) + + let primaryRef = WorkspaceRootRef( + id: UUID(), + name: "primary", + fullPath: primaryFixture.repo.path + ) + let secondaryRef = WorkspaceRootRef( + id: UUID(), + name: "secondary", + fullPath: secondaryFixture.repo.path + ) + let discoveryRoots = AgentMCPStartWorktreeCoordinator.repositoryDiscoveryRoots( + primaryRoot: primaryFixture.repo.path, + visibleRoots: [secondaryRef, primaryRef] + ) + XCTAssertEqual(discoveryRoots, [primaryRef, secondaryRef]) + + let window = try await makeWindow(roots: [primaryFixture.repo, secondaryFixture.repo]) + let workspace = try XCTUnwrap(window.workspaceManager.activeWorkspace) + XCTAssertEqual(workspace.repoPaths.first, primaryFixture.repo.path) + + for testCase in [ + (label: "implicit worktree id", key: "worktree_id", selector: descriptor.worktreeID, repoRoot: nil), + (label: "implicit branch selector", key: "worktree", selector: "@branch:\(branch)", repoRoot: nil), + (label: "explicit worktree id", key: "worktree_id", selector: descriptor.worktreeID, repoRoot: primaryFixture.repo.path), + (label: "explicit branch selector", key: "worktree", selector: "@branch:\(branch)", repoRoot: primaryFixture.repo.path) + ] { + let service = makeAgentRunStartService(window: window, sourceTabID: nil) + var args: [String: Value] = [ + "op": .string("start"), + "message": .string(testCase.label), + "detach": .bool(true), + "timeout": .int(0), + testCase.key: .string(testCase.selector) + ] + if let repoRoot = testCase.repoRoot { + args["worktree_repo_root"] = .string(repoRoot) + } + let value = try await service.execute(args: args) + let object = try XCTUnwrap(value.objectValue, testCase.label) + let bindings = try XCTUnwrap(object["worktree_bindings"]?.arrayValue, testCase.label) + let binding = try XCTUnwrap(bindings.first?.objectValue, testCase.label) + XCTAssertEqual(bindings.count, 1, testCase.label) + XCTAssertEqual(binding["worktree_id"]?.stringValue, descriptor.worktreeID, testCase.label) + XCTAssertEqual(binding["logical_root_path"]?.stringValue, primaryFixture.repo.path, testCase.label) + XCTAssertEqual(binding["worktree_root_path"]?.stringValue, descriptor.path, testCase.label) + } + } + + func testAgentStartWorktreeSelectorsRejectCrossRepositoryMatchesForImplicitAndExplicitPrimaryRoot() async throws { + let primaryFixture = try makeGitFixture() + let secondaryFixture = try makeGitFixture() + let branch = "feature/cross-repo-selector-\(secondaryFixture.suffix)" + let secondaryWorktree = secondaryFixture.sandbox.appendingPathComponent( + "cross-repo-selector-worktree", + isDirectory: true + ) + try runGit( + ["worktree", "add", "-b", branch, secondaryWorktree.path, "HEAD"], + cwd: secondaryFixture.repo + ) + let expectedHead = try runGitOutput(["rev-parse", "HEAD"], cwd: secondaryFixture.repo) + let descriptor = try await GitWorktreeTestSupport.waitForStableDescriptor( + repo: secondaryFixture.repo, + path: secondaryWorktree, + expectedBranch: branch, + expectedHead: expectedHead, + listDescriptors: { try await VCSService.shared.listGitWorktrees(at: secondaryFixture.repo) } + ) + let window = try await makeWindow(roots: [primaryFixture.repo, secondaryFixture.repo]) + let viewModel = window.agentModeViewModel + let initialTabCount = try XCTUnwrap(window.workspaceManager.activeWorkspace).composeTabs.count + let initialSessionCount = viewModel.sessions.count + + for testCase in [ + (label: "implicit worktree id", key: "worktree_id", selector: descriptor.worktreeID, repoRoot: nil), + (label: "implicit branch selector", key: "worktree", selector: "@branch:\(branch)", repoRoot: nil), + (label: "explicit worktree id", key: "worktree_id", selector: descriptor.worktreeID, repoRoot: primaryFixture.repo.path), + (label: "explicit branch selector", key: "worktree", selector: "@branch:\(branch)", repoRoot: primaryFixture.repo.path) + ] { + let service = makeAgentRunStartService(window: window, sourceTabID: nil) + var args: [String: Value] = [ + "op": .string("start"), + "message": .string(testCase.label), + "detach": .bool(true), + "timeout": .int(0), + testCase.key: .string(testCase.selector) + ] + if let repoRoot = testCase.repoRoot { + args["worktree_repo_root"] = .string(repoRoot) + } + do { + _ = try await service.execute(args: args) + XCTFail("Expected cross-repository selector rejection: \(testCase.label)") + } catch { + XCTAssertTrue(error.localizedDescription.contains("No worktree found"), testCase.label) + } + XCTAssertEqual(window.workspaceManager.activeWorkspace?.composeTabs.count, initialTabCount, testCase.label) + XCTAssertEqual(viewModel.sessions.count, initialSessionCount, testCase.label) + } + } + + func testAgentStartWorktreeSelectorRejectsSecondaryGitRepoWhenPrimaryRootIsNonGit() async throws { + let primaryRoot = try makeTemporaryDirectory(named: "non-git-primary-root") + let secondaryFixture = try makeGitFixture() + let branch = "feature/secondary-selector-\(secondaryFixture.suffix)" + let secondaryWorktree = secondaryFixture.sandbox.appendingPathComponent( + "secondary-selector-worktree", + isDirectory: true + ) + try runGit( + ["worktree", "add", "-b", branch, secondaryWorktree.path, "HEAD"], + cwd: secondaryFixture.repo + ) + let expectedHead = try runGitOutput(["rev-parse", "HEAD"], cwd: secondaryFixture.repo) + let descriptor = try await GitWorktreeTestSupport.waitForStableDescriptor( + repo: secondaryFixture.repo, + path: secondaryWorktree, + expectedBranch: branch, + expectedHead: expectedHead, + listDescriptors: { try await VCSService.shared.listGitWorktrees(at: secondaryFixture.repo) } + ) + let window = try await makeWindow(roots: [primaryRoot, secondaryFixture.repo]) + let initialTabCount = try XCTUnwrap(window.workspaceManager.activeWorkspace).composeTabs.count + + let service = makeAgentRunStartService(window: window, sourceTabID: nil) + do { + _ = try await service.execute(args: [ + "op": .string("start"), + "message": .string("reject secondary repository"), + "detach": .bool(true), + "timeout": .int(0), + "worktree_id": .string(descriptor.worktreeID) + ]) + XCTFail("Expected a secondary-repository worktree to be rejected when the primary root is non-Git") + } catch { + XCTAssertTrue(error.localizedDescription.contains("supports the primary workspace root only")) + } + XCTAssertEqual(window.workspaceManager.activeWorkspace?.composeTabs.count, initialTabCount) + } + func testSharedStartWorktreeCoordinatorHonorsPreCancelledCreateWithoutMutation() async throws { let fixture = try makeGitFixture() let window = try await makeWindow(root: fixture.repo) diff --git a/Tests/RepoPromptTests/MCP/ToolCatalogSnapshotTests.swift b/Tests/RepoPromptTests/MCP/ToolCatalogSnapshotTests.swift index 5c6ec4f0f..11373a9cf 100644 --- a/Tests/RepoPromptTests/MCP/ToolCatalogSnapshotTests.swift +++ b/Tests/RepoPromptTests/MCP/ToolCatalogSnapshotTests.swift @@ -487,8 +487,8 @@ final class ToolCatalogSnapshotTests: XCTestCase { "14|manage_worktree|enabled=true|ann=title=nil,readOnly=false,destructive=true,idempotent=nil,openWorld=false|desc=857ab8975667e3d2e5b35a09c7415e07ca0ab2f0ff16de6895170d4d1b47a820|schema=9263f9f047982b3709d92040f749804d69928d222ce46038a4171ded34d12bc6", "15|context_builder|enabled=true|ann=title=nil,readOnly=false,destructive=false,idempotent=nil,openWorld=false|desc=d83348b6b803b303965401075041ddc5d7dcea3512020afa3f352c04413750fb|schema=2da87e6e171809a1e0eb0614fa8f7db2f91311f655f8427745060be80755da1f", "16|ask_user|enabled=true|ann=title=nil,readOnly=false,destructive=false,idempotent=nil,openWorld=false|desc=6b3870ae4848eb01c73de9fbbdf2ed1782487db150260469853757f799257ee0|schema=080446bb7697cf5f4cd31f07b42ecff8ab29edc8501ee0e84e61426748569156", - "17|agent_explore|enabled=true|ann=title=nil,readOnly=false,destructive=false,idempotent=nil,openWorld=false|desc=698ab006db47713a51f394bfe3f832ada8637440d8acb4715be5430ec380cef8|schema=7b3c869b0c959c1c162dfadfd4ea578b05ed0834b2e930d177a8c38f96c31a4b", - "18|agent_run|enabled=true|ann=title=nil,readOnly=false,destructive=false,idempotent=nil,openWorld=false|desc=9ce2d8314f17c1b37ce3bc41b699cb9b6e0d3190ae7a66da6a5f8152109214df|schema=1d86e85b1dd38bc0438d8d002230bcadbe80c21ae621d0a2dab9e4c9d0af5f65", + "17|agent_explore|enabled=true|ann=title=nil,readOnly=false,destructive=false,idempotent=nil,openWorld=false|desc=698ab006db47713a51f394bfe3f832ada8637440d8acb4715be5430ec380cef8|schema=d367738ad179d8f6b39b98f73082d594f53c42d771c4f2e512790593c5b3f9f4", + "18|agent_run|enabled=true|ann=title=nil,readOnly=false,destructive=false,idempotent=nil,openWorld=false|desc=9ce2d8314f17c1b37ce3bc41b699cb9b6e0d3190ae7a66da6a5f8152109214df|schema=e3bd3dd90b43617a964b62b0f6b130f4199466d52b50508204428333211f2603", "19|agent_manage|enabled=true|ann=title=nil,readOnly=false,destructive=false,idempotent=nil,openWorld=false|desc=03e16bee789cb9343f6b1b16cb4d472aedd3d811a43f6f95ad8ea5e8f69dc28d|schema=f5bc6b05cf0683ef3acb7a82ee4a14b75fadf26f32c56b0314be1424688a2ba5", "20|share_thoughts|enabled=true|ann=title=nil,readOnly=false,destructive=false,idempotent=nil,openWorld=false|desc=b1ac755b39a4ac2d8a621e78801a258c5d95ec2ff4e063f600081fa27891a852|schema=a5dea0c92fd4da06a15f991e1e8a287235ca681ae381cef1b594bc7c07e538d7", "21|set_status|enabled=true|ann=title=nil,readOnly=false,destructive=false,idempotent=nil,openWorld=false|desc=19bbfd6fc47639e02295de4e9289ea77f25c6a91ad150998726768b84c266783|schema=0854d727c81f1eb8fa0a14edb9d6ab8bb58974d919cc53150bd72473f1ae0196", From b7e0e7f51afd05aa360dc6feba05f48ea91d7c4c Mon Sep 17 00:00:00 2001 From: MP Date: Thu, 16 Jul 2026 11:30:20 +0800 Subject: [PATCH 05/21] fix: preserve Codex exit evidence under load --- .../Fixtures/test-suite-contract-ledger.tsv | 5 +- .../AppServer/CodexAppServerClient.swift | 57 +++-- .../Process/ChildProcessExitObserver.swift | 50 +++-- .../Process/ProcessTermination.swift | 210 +++++++++++++++--- .../Infrastructure/VCS/GitService.swift | 8 +- .../ProcessTerminationExitStatusTests.swift | 203 +++++++++++++++++ ...CodexAppServerClientProcessExitTests.swift | 175 ++++++++++++++- 7 files changed, 624 insertions(+), 84 deletions(-) diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index 37b924b2a..5afc7f50b 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -674,11 +674,13 @@ root/RepoPromptTests.CLIProcessRunnerLifecycleTests/testCancelAllTerminatesRepar root/RepoPromptTests.CLIProcessRunnerLifecycleTests/testStreamingProcessLifecycleCallbacksUseSamePIDAndTerminate root Tests/RepoPromptTests/AI/CLIProcessRunnerLifecycleTests.swift RepoPromptTests.CLIProcessRunnerLifecycleTests testStreamingProcessLifecycleCallbacksUseSamePIDAndTerminate AI unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.065500 unreviewed retain_pending_review 0 initial census source line 6 root/RepoPromptTests.CLIProcessRunnerLifecycleTests/testStreamingProcessTerminationCallbackRunsAfterRunnerCancellation root Tests/RepoPromptTests/AI/CLIProcessRunnerLifecycleTests.swift RepoPromptTests.CLIProcessRunnerLifecycleTests testStreamingProcessTerminationCallbackRunsAfterRunnerCancellation AI unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.066000 unreviewed retain_pending_review 0 initial census source line 40 root/RepoPromptTests.CLIProcessRunnerLifecycleTests/testTerminateAndReapUsesSpawnedProcessGroupForDirectProviderStyleProcess root Tests/RepoPromptTests/AI/CLIProcessRunnerLifecycleTests.swift RepoPromptTests.CLIProcessRunnerLifecycleTests testTerminateAndReapUsesSpawnedProcessGroupForDirectProviderStyleProcess AI ai.cli_process_runner.terminate_and_reap_uses_spawned_process_group process_group,direct_provider,descendant_reaping subprocess_lifecycle_regression root_swiftpm routine 1 temporary_pid_marker Terminating a directly spawned provider-style shell reaps the same-process-group descendant recorded by the marker file. Direct provider launches could terminate only the parent and leak child processes. subprocess;filesystem;async_wait test_case retain 0 origin/main reconciliation after commits 5c1504f1/dc79e7a4: reviewed direct-provider process-group reap coverage. +root/RepoPromptTests.ProcessTerminationExitStatusTests/testBlockedOutcomePublicationDoesNotDelayAnotherChildReap root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testBlockedOutcomePublicationDoesNotDelayAnotherChildReap AI ai.process_termination.child_exit_observer.publication_isolation waitpid,dispatch_source,observer,publication_gate,echild subprocess_lifecycle_regression root_swiftpm routine 2 BlockedOutcomePublicationGate Child A closes its PID-signaling window while outcome publication is gated; child B still publishes exited(23) and is already reaped, then releasing A publishes exited(17) and confirms its reap. A blocked diagnostic publication hook could stall the serial registry, delay unrelated Codex exit evidence, or reopen competing PID signaling and waitpid ownership. subprocess;async_wait gate+actor_cleanup+addTeardownBlock retain 0 Worktree-bound Codex startup REVIEW remediation: deterministic publication isolation and sole-reaper coverage. root/RepoPromptTests.ProcessTerminationExitStatusTests/testBlockingReapChildStatusPreservesExitAndSignalSemantics root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testBlockingReapChildStatusPreservesExitAndSignalSemantics AI ai.process_termination.blocking_reap_status waitpid,blocking_reap,exit_status,signal,reap_callback subprocess_lifecycle_regression root_swiftpm routine 2 GitProcessLifecycleTarget A dedicated off-executor blocking waitpid reaps normal and signaled children with exact status semantics and marks lifecycle state before await resumes. Git completion could poll unnecessarily, diverge from exit decoding, or expose a post-reap lifecycle signaling gap. 0.100000 subprocess;async_wait test_case retain 0 Oracle P1 process-launch follow-up: event-driven sole-reaper and synchronous post-waitpid hook coverage. root/RepoPromptTests.ProcessTerminationExitStatusTests/testBlockingReapChildStatusTreatsECHILDAsOwnershipError root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testBlockingReapChildStatusTreatsECHILDAsOwnershipError AI ai.process_termination.blocking_reap_echild_ownership_error waitpid,blocking_reap,echild,ownership subprocess_lifecycle_regression root_swiftpm routine 1 A second sole-reaper wait for an already reaped child throws childOwnershipLost instead of fabricating exited(0). ECHILD could be misreported as a successful zero exit and conceal competing waitpid ownership. 0.050000 subprocess;async_wait test_case retain 0 GPT-5.6 Sol Ultra P1 follow-up: ECHILD ownership-error coverage. root/RepoPromptTests.ProcessTerminationExitStatusTests/testChildProcessExitObserverSharesOneDetailedReap root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testChildProcessExitObserverSharesOneDetailedReap AI ai.process_termination.child_exit_observer.shared_sole_reap waitpid,observer,exit_status,echild,sole_reaper subprocess_lifecycle_regression root_swiftpm routine 1 ChildProcessExitObserver Repeated observer waits share exited(19), and a later direct reap reports childOwnershipLost. Multiple lifecycle consumers could perform competing destructive waits or fabricate exit evidence. subprocess;async_wait test_case retain 0 WI-2 sole-reaper observation coverage. root/RepoPromptTests.ProcessTerminationExitStatusTests/testDecodeWaitStatusPreservesExitSignalAndFallbackSemantics root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testDecodeWaitStatusPreservesExitSignalAndFallbackSemantics AI ai.process_termination.detailed_status_decode waitpid,exit_status,signal,fallback deterministic_unit root_swiftpm fast 4 Raw waitpid statuses decode to exited/uncaughtSignal cases whose normalized, terminationStatus, and terminationReason projections all agree. Detailed decoding could diverge from the historical 128+signal normalization or from NSTask terminationStatus parity. 0.000000 test_case retain 0 posix_spawn git launch migration: new behavioral coverage for ProcessLauncher-based GitService spawning. root/RepoPromptTests.ProcessTerminationExitStatusTests/testObservedTerminationEscalatesBeforeGroupOnlyCleanup root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testObservedTerminationEscalatesBeforeGroupOnlyCleanup AI ai.process_termination.observed_term_kill_group_cleanup sigterm,sigkill,process_group,root_reap,descendant_cleanup subprocess_lifecycle_regression root_swiftpm routine 1 sigterm_ignoring_process_family Observer-aware teardown escalates a TERM-resistant family to KILL, observes the root signal, removes the descendant, and leaves ECHILD for any second reap. Teardown could race a second waitpid, signal a reused root PID, or leak descendants. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 observer-aware teardown coverage. +root/RepoPromptTests.ProcessTerminationExitStatusTests/testTerminalChildProbeDoesNotConsumeExitStatus root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testTerminalChildProbeDoesNotConsumeExitStatus AI ai.process_termination.child_status.nondestructive_terminal_probe waitid,wnowait,waitpid,exit_status,echild subprocess_lifecycle_regression root_swiftpm routine 2 The terminal-child probe detects both a zombie and an already-reaped child while leaving exited(31) available to the sole destructive reaper. A diagnostic probe could consume typed exit evidence or mistake an already-reaped root for a live process. subprocess;async_wait test_case retain 0 Worktree-bound Codex startup EOF arbitration regression coverage. root/RepoPromptTests.ProcessTerminationExitStatusTests/testWaitForTerminationStatusReportsRealChildExitAndSignal root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testWaitForTerminationStatusReportsRealChildExitAndSignal AI ai.process_termination.detailed_status_reap waitpid,exit_status,signal,reaping subprocess_lifecycle_regression root_swiftpm routine 2 Real children reaped through waitForTerminationStatus report exited(3) and uncaughtSignal(SIGKILL) with normalized parity. The detailed wait variant could mis-decode real waitpid statuses or diverge from the normalized API for signal exits. 0.300000 subprocess;async_wait test_case retain 0 posix_spawn git launch migration: new behavioral coverage for ProcessLauncher-based GitService spawning. root/RepoPromptTests.ChatHistoryJSONOnlyTests/testCurrentChatSessionSaveLoadUsesCEWorkspaceRoot root Tests/RepoPromptTests/ChatHistoryJSONOnlyTests.swift RepoPromptTests.ChatHistoryJSONOnlyTests testCurrentChatSessionSaveLoadUsesCEWorkspaceRoot Root unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.001500 unreviewed retain_pending_review 0 initial census source line 5 root/RepoPromptTests.ChatHistoryJSONOnlyTests/testLegacyChatSessionEditPayloadsAreIgnoredOnDecodeAndOmittedOnEncode root Tests/RepoPromptTests/ChatHistoryJSONOnlyTests.swift RepoPromptTests.ChatHistoryJSONOnlyTests testLegacyChatSessionEditPayloadsAreIgnoredOnDecodeAndOmittedOnEncode Root unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 70 @@ -1037,8 +1039,9 @@ root/RepoPromptTests.CodexAppServerClientProcessExitTests/testExplicitStopWinsOv root/RepoPromptTests.CodexAppServerClientProcessExitTests/testListModelsRetriesTypedProcessExitOnceOnFreshProcess root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testListModelsRetriesTypedProcessExitOnceOnFreshProcess AgentMode/Codex codex.app_server.model_list.typed_exit_retry model_list,process_exit,retry,fresh_process subprocess_lifecycle_regression root_swiftpm routine 1 exit_then_model_codex_fixture A typed processExited failure during model/list receives one fresh-process retry and returns the successful replacement process model response. Typed exit recovery could regress from the established EOF recovery behavior and leave model discovery unavailable after a transient app-server exit. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 typed-exit model-list recovery coverage. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStaleObservedExitCannotMutateReplacementGeneration root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStaleObservedExitCannotMutateReplacementGeneration AgentMode/Codex codex.app_server.lifecycle.stale_generation_exit_rejection generation,observer_identity,replacement async_concurrency_lifecycle root_swiftpm routine 1 persistent_codex_fixture A delivered old-generation exit leaves the replacement PID, generation, running state, and termination reason unchanged. Late observer callbacks could poison a replacement transport. subprocess;filesystem;actor test_case+fixture_cleanup retain 0 WI-2 generation-safety coverage. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStopDuringRestartPreparationPreventsSpawnAfterReturn root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStopDuringRestartPreparationPreventsSpawnAfterReturn AgentMode/Codex codex.app_server.lifecycle.stop_restart_spawn_fence stop,restart,startup_cancellation,pre_spawn,termination_retirement async_concurrency_lifecycle root_swiftpm routine 1 persistent_codex_fixture,ProcessSpawnPreparationGate A stop during blocked restart preparation returns only after revoking startup authority; releasing preparation afterward cannot produce a second process spawn or live transport. A settled teardown task could let stop return without cancelling a restart, allowing an app-server to spawn and survive after shutdown. subprocess;filesystem;actor;async_wait test_case+fixture_cleanup retain 0 WI-2 restart pre-spawn shutdown regression coverage. -root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStartupEOFReturnsTypedExitWithSettledBoundedStderr root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStartupEOFReturnsTypedExitWithSettledBoundedStderr AgentMode/Codex codex.app_server.process_exit.typed_stderr_evidence stdout_eof,exit_status,stderr_tail,truncation,descendant_stderr,family_cleanup,first_claim,shared_completion subprocess_lifecycle_regression root_swiftpm routine 2 early_exit_codex_fixture EOF before exit performs bounded family cleanup and stderr drain before returning processExited with the exact final 8 KiB raw suffix; a losing stop remains pending until the winning settlement completes. Startup failures could snapshot incomplete descendant-held stderr, lose actionable evidence, or let duplicate lifecycle callers return before teardown completes. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 bounded startup-diagnostic, descendant cleanup, and shared-completion coverage. +root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStartupEOFReturnsTypedExitWithSettledBoundedStderr root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStartupEOFReturnsTypedExitWithSettledBoundedStderr AgentMode/Codex codex.app_server.process_exit.typed_stderr_evidence stdout_eof,exit_status,terminal_probe,observer_join,root_signal_window,stderr_tail,truncation,descendant_stderr,family_cleanup,first_claim,shared_completion subprocess_lifecycle_regression root_swiftpm routine 4 early_exit_codex_fixture,ChildExitOutcomePublicationGate A post-waitpid publication gate holds beyond the diagnostic window while root-PID signaling is already closed, forcing the non-destructive terminal probe and sole-observer join before processExited returns the exact final 8 KiB raw stderr suffix; a losing stop remains pending until the winning settlement completes. Startup EOF could signal a reaped or reused PID, collapse a real exit into processNotRunning, consume status with a second reap, snapshot incomplete descendant-held stderr, or let duplicate lifecycle callers return before teardown completes. subprocess;filesystem;async_wait;dispatch_wait test_case+fixture_cleanup retain 0 Deterministic kernel-boundary signal closure, terminal-probe arbitration, bounded startup diagnostics, descendant cleanup, and shared-completion coverage. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStartupSignalExitKeepsSignalSemanticsAndOmitsEmptyStderr root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStartupSignalExitKeepsSignalSemanticsAndOmitsEmptyStderr AgentMode/Codex codex.app_server.process_exit.signal_evidence signal,empty_stderr,typed_exit subprocess_lifecycle_regression root_swiftpm routine 1 early_exit_codex_fixture A SIGKILL startup exit remains uncaughtSignal(SIGKILL), retains empty raw stderr, and omits stderr from rendered diagnostics. Signal exits could be normalized into ambiguous integer codes or render empty diagnostic noise. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 typed exit-evidence coverage. +root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStartupStdoutEOFWhileRootLivesKeepsGenericFailure root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStartupStdoutEOFWhileRootLivesKeepsGenericFailure AgentMode/Codex codex.app_server.process_exit.live_root_stdout_eof stdout_eof,live_root,generic_failure,termination subprocess_lifecycle_regression root_swiftpm routine 1 live_after_stdout_eof_codex_fixture EOF from a still-running root retains processNotRunning with stdoutEOF and proceeds through normal termination instead of waiting indefinitely for typed exit. Terminal-exit arbitration could join a live root observer forever or fabricate typed evidence when only stdout closed. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 Worktree-bound Codex startup EOF arbitration converse coverage. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStderrCaptureRetainsExactRawSuffixAtEveryBoundary root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStderrCaptureRetainsExactRawSuffixAtEveryBoundary AgentMode/Codex codex.app_server.stderr_tail.byte_boundaries stderr,byte_cap,truncation,invalid_utf8,multichunk deterministic_unit root_swiftpm fast 6 CodexProcessStderrCapture Empty, 1, 8191, 8192, 8193, and multi-chunk invalid-UTF-8 inputs retain the exact expected suffix and truncation bit. Boundary drift could lose bytes, overrun the cap, or decode evidence before rendering. test_case retain 0 WI-2 raw stderr capture coverage. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testTimeoutPoisoningWinsOverObservedTermination root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testTimeoutPoisoningWinsOverObservedTermination AgentMode/Codex codex.app_server.lifecycle.timeout_precedence timeout,transport_poisoning,observer_exit,first_claim subprocess_lifecycle_regression root_swiftpm routine 1 persistent_codex_fixture A timed-out thread/start retains requestFailed timeout and the timeout termination reason after observer-driven teardown. Timeout poisoning could be relabeled as a process crash and lose its request-level meaning. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 lifecycle-precedence coverage. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testTransportWriteFailureWinsOverObservedTermination root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testTransportWriteFailureWinsOverObservedTermination AgentMode/Codex codex.app_server.lifecycle.stdin_write_precedence stdin,EPIPE,observer_exit,first_claim subprocess_lifecycle_regression root_swiftpm routine 1 persistent_codex_fixture,FDWriteError An injected EPIPE during initialize remains transportWriteFailed with stdinWrite lifecycle reason after observer-owned teardown. A write failure could collapse to processNotRunning or processExited and hide the actionable errno. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 lifecycle-precedence coverage. diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift index ba8dcc371..e1d7d5767 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift @@ -389,7 +389,11 @@ actor CodexAppServerClient { private let writeFrameHandler: @Sendable (Int32, Data) throws -> Void private let livenessProbe: @Sendable (SpawnedProcess) -> Bool private let processSpawnPreparation: @Sendable () async throws -> Void + private let processExitObserverFactory: @Sendable (pid_t) -> ChildProcessExitObserver private let expectedAgentPIDRegistrar: ExpectedAgentPIDRegistrar + #if DEBUG + private var terminalObserverJoinCount = 0 + #endif deinit { emergencyTerminateTransportForDeinit() @@ -403,11 +407,15 @@ actor CodexAppServerClient { CodexAppServerClient.defaultProcessAppearsAlive(process) }, processSpawnPreparation: @escaping @Sendable () async throws -> Void = {}, + processExitObserverFactory: @escaping @Sendable (pid_t) -> ChildProcessExitObserver = { + ChildProcessExitObserver(pid: $0) + }, expectedAgentPIDRegistrar: ExpectedAgentPIDRegistrar = .serverNetworkManager ) { self.writeFrameHandler = writeFrameHandler self.livenessProbe = livenessProbe self.processSpawnPreparation = processSpawnPreparation + self.processExitObserverFactory = processExitObserverFactory self.expectedAgentPIDRegistrar = expectedAgentPIDRegistrar } @@ -765,15 +773,35 @@ actor CodexAppServerClient { await existing.task.value return } - if let observer = activeTransport?.exitObservation?.observer, - let outcome = await observer.wait(timeout: Self.exitDiagnosticSettlementWindow) + if let activeTransport, + activeTransport.generation == generation, + let observer = activeTransport.exitObservation?.observer { - await handleObservedProcessExit( - outcome, - observer: observer, - generation: generation - ) - return + if let outcome = await observer.wait(timeout: Self.exitDiagnosticSettlementWindow) { + await handleObservedProcessExit( + outcome, + observer: observer, + generation: generation + ) + return + } + + // A terminal root can race the bounded diagnostic wait without + // making a reused-PID signal safe. Join the sole observer so its + // typed exit survives; waitid remains non-destructive. + if ProcessTermination.childIsTerminalOrAlreadyReaped(activeTransport.process.pid) { + #if DEBUG + terminalObserverJoinCount += 1 + #endif + if let outcome = await observer.wait() { + await handleObservedProcessExit( + outcome, + observer: observer, + generation: generation + ) + return + } + } } await terminateTransport( flushStdout: flushStdout, @@ -979,12 +1007,7 @@ actor CodexAppServerClient { private static func defaultProcessAppearsAlive(_ process: SpawnedProcess) -> Bool { // Use a non-destructive child-state check so exited/zombie children do not // look healthy, while leaving final reap/cleanup to the normal teardown path. - var info = siginfo_t() - let waitResult = Darwin.waitid(P_PID, id_t(process.pid), &info, WEXITED | WNOHANG | WNOWAIT) - if waitResult == 0, info.si_pid == process.pid { - return false - } - if waitResult == -1, errno == ECHILD { + if ProcessTermination.childIsTerminalOrAlreadyReaped(process.pid) { return false } @@ -1260,7 +1283,7 @@ actor CodexAppServerClient { // every successful spawn immediately has one cancellation-independent reaper. transportGeneration &+= 1 let generation = transportGeneration - let exitObserver = ChildProcessExitObserver(pid: spawned.pid) + let exitObserver = processExitObserverFactory(spawned.pid) let capture = CodexProcessStderrCapture(byteLimit: Self.stderrTailLimit) stdoutFramer = LineFramer() @@ -1952,6 +1975,10 @@ actor CodexAppServerClient { defaultProcessAppearsAlive(process) } + func debugTerminalObserverJoinCount() -> Int { + terminalObserverJoinCount + } + func debugDecodeRecoveryAttempts(generation: UInt64? = nil) -> Int { let key = generation ?? transportGeneration return decodeRecoveryAttemptsByGeneration[key, default: 0] diff --git a/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift b/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift index 0298afbcf..c13febfba 100644 --- a/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift +++ b/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift @@ -1,9 +1,10 @@ import Darwin +import Dispatch import Foundation /// One cancellation-independent owner for a direct child's destructive reap. -/// Callers may wait repeatedly, but only the detached observation task invokes -/// `waitpid` through `ProcessTermination.reapChildStatus`. +/// Callers may wait repeatedly, but only the callback-based observation invokes +/// `waitpid` through `ProcessTermination.observeChildStatus`. final class ChildProcessExitObserver: @unchecked Sendable { enum Outcome: Equatable { case exited(ProcessExitStatus) @@ -29,8 +30,8 @@ final class ChildProcessExitObserver: @unchecked Sendable { return } self.outcome = outcome - // An ownership failure also closes PID signaling. Another reaper may - // already have consumed the child, so a PID fallback is no longer safe. + // Keep publication idempotently closed to PID signaling even if a + // future result path reaches finish without the callback boundary. rootReaped = true let continuations = waiters.values waiters.removeAll() @@ -81,26 +82,33 @@ final class ChildProcessExitObserver: @unchecked Sendable { let pid: pid_t private let state: State + private static let outcomePublicationQueue = DispatchQueue( + label: "com.repoprompt.child-process-exit-observer.outcome-publication", + qos: .userInitiated, + attributes: .concurrent + ) - init(pid: pid_t) { + init( + pid: pid_t, + beforePublishingOutcome: @escaping @Sendable (Outcome) -> Void = { _ in } + ) { self.pid = pid let state = State() self.state = state - Task.detached { - let outcome: Outcome - do { - let status = try await ProcessTermination.reapChildStatus( - pid: pid, - onReaped: { state.markRootReaped() } - ) - outcome = .exited(status) - } catch let error as ProcessTerminationError { - outcome = .failed(error) - } catch { - outcome = .failed(.waitFailed(error.localizedDescription)) + ProcessTermination.observeChildStatus(pid: pid) { result in + state.markRootReaped() + let outcome: Outcome = switch result { + case let .success(status): .exited(status) + case let .failure(error): .failed(error) + } + // Publication hooks may deliberately block for diagnostics. Keep + // them off the serial reaper registry so unrelated children can + // continue closing their ownership windows and publishing exits. + Self.outcomePublicationQueue.async { + beforePublishingOutcome(outcome) + state.finish(with: outcome) } - state.finish(with: outcome) } } @@ -108,9 +116,9 @@ final class ChildProcessExitObserver: @unchecked Sendable { await state.wait(timeout: timeout) } - /// Signals only while this observer still owns an unreaped root PID. Holding - /// the same lock used by `onReaped` closes the lifecycle signaling window at - /// the sole-reaper boundary rather than after actor scheduling. + /// Signals only while this observer still owns an unreaped root PID. The + /// callback closes this lock-protected window immediately after waitpid, + /// before diagnostic outcome publication can block or change executors. func signalRootProcessFamilyIfUnreaped( processGroupID: pid_t?, signal: Int32, diff --git a/Sources/RepoPrompt/Infrastructure/Process/ProcessTermination.swift b/Sources/RepoPrompt/Infrastructure/Process/ProcessTermination.swift index 5efd03fe8..42a7fc688 100644 --- a/Sources/RepoPrompt/Infrastructure/Process/ProcessTermination.swift +++ b/Sources/RepoPrompt/Infrastructure/Process/ProcessTermination.swift @@ -55,6 +55,146 @@ enum ProcessTerminationError: Error, Equatable, LocalizedError { } } +/// Process-wide ownership registry for direct-child exit observation. A process +/// source scales with the number of live children without occupying one worker +/// thread per child, while the serial queue preserves exactly one destructive +/// `waitpid` owner for each PID. +private final class ChildStatusReaperRegistry: @unchecked Sendable { + static let shared = ChildStatusReaperRegistry() + + private enum ReapMode { + case nonblockingProbe + case exitNotification + + var waitOptions: Int32 { + switch self { + case .nonblockingProbe: WNOHANG + case .exitNotification: 0 + } + } + } + + private final class Entry { + let token: UUID + let source: any DispatchSourceProcess + let completion: @Sendable (Result) -> Void + + init( + token: UUID, + source: any DispatchSourceProcess, + completion: @escaping @Sendable (Result) -> Void + ) { + self.token = token + self.source = source + self.completion = completion + } + } + + private let queue = DispatchQueue( + label: "com.repoprompt.process-termination.child-status-registry", + qos: .userInitiated + ) + private var entries: [pid_t: Entry] = [:] + + func observe( + pid: pid_t, + completion: @escaping @Sendable (Result) -> Void + ) { + queue.async { [weak self] in + guard let self else { return } + guard entries[pid] == nil else { + completion(.failure(.childOwnershipLost(pid: pid))) + return + } + + let token = UUID() + let source = DispatchSource.makeProcessSource( + identifier: pid, + eventMask: .exit, + queue: queue + ) + let entry = Entry(token: token, source: source, completion: completion) + entries[pid] = entry + + source.setRegistrationHandler { [weak self] in + self?.probeWithoutBlocking(pid: pid, token: token) + } + source.setEventHandler { [weak self] in + self?.reapAfterExitNotification(pid: pid, token: token) + } + source.activate() + + // Activation and registration are distinct libdispatch steps. This + // probe plus the registration-handler probe cover exits on either + // side of that boundary without consuming a live child's status. + probeWithoutBlocking(pid: pid, token: token) + } + } + + private func probeWithoutBlocking(pid: pid_t, token: UUID) { + reap(pid: pid, token: token, mode: .nonblockingProbe) + } + + private func reapAfterExitNotification(pid: pid_t, token: UUID) { + reap(pid: pid, token: token, mode: .exitNotification) + } + + private func reap(pid: pid_t, token: UUID, mode: ReapMode) { + guard entries[pid]?.token == token else { return } + + var status: Int32 = 0 + while true { + let result = waitpid(pid, &status, mode.waitOptions) + if result == pid { + complete( + pid: pid, + token: token, + result: .success(ProcessTermination.decodeWaitStatus(status)) + ) + return + } + if result == 0 { + switch mode { + case .nonblockingProbe: + return + case .exitNotification: + complete( + pid: pid, + token: token, + result: .failure(.waitFailed("waitpid returned no status after process exit notification")) + ) + return + } + } + if result == -1, errno == EINTR { + continue + } + if result == -1, errno == ECHILD { + complete(pid: pid, token: token, result: .failure(.childOwnershipLost(pid: pid))) + return + } + let message = String(cString: strerror(errno)) + complete(pid: pid, token: token, result: .failure(.waitFailed(message))) + return + } + } + + private func complete( + pid: pid_t, + token: UUID, + result: Result + ) { + guard let entry = entries[pid], entry.token == token else { return } + + // Lifecycle owners close their PID-signaling window synchronously at + // the destructive reap boundary, before this PID can be registered again. + entry.completion(result) + guard entries[pid]?.token == token else { return } + entries.removeValue(forKey: pid) + entry.source.cancel() + } +} + enum ProcessTermination { private struct TerminationTiming { let cooperativeWaitTimeout: TimeInterval @@ -73,12 +213,6 @@ enum ProcessTermination { private static let appTerminationCooperativeWaitTimeout: TimeInterval = 0.75 private static let terminationModeLock = NSLock() private static var appTerminationFastPathEnabled = false - private static let blockingReapQueue = DispatchQueue( - label: "com.repoprompt.process-termination.waitpid", - qos: .userInitiated, - attributes: .concurrent - ) - static func beginAppTerminationFastPath() { terminationModeLock.lock() appTerminationFastPathEnabled = true @@ -331,43 +465,45 @@ enum ProcessTermination { return decodeWaitStatus(status) } - /// Reaps one direct child with a blocking kernel wait performed outside - /// Swift's cooperative executor. Git owns cancellation and timeout - /// signaling separately; this method is deliberately the sole waitpid - /// authority and never abandons the child on task cancellation. - /// - /// The callback runs synchronously on the blocking reaper queue after a - /// successful waitpid and before the async continuation is resumed. This - /// lets lifecycle owners close their PID-signaling window at the kernel - /// reap boundary rather than after executor scheduling. + /// Registers one cancellation-independent direct-child observation. The + /// completion runs synchronously on the serial process-wide registry queue + /// after the destructive reap. It must close PID-signaling state before it + /// returns and return promptly so unrelated child exits can be processed. + static func observeChildStatus( + pid: pid_t, + completion: @escaping @Sendable (Result) -> Void + ) { + ChildStatusReaperRegistry.shared.observe(pid: pid, completion: completion) + } + + /// Checks child terminal state without consuming the status owned by the + /// sole reaper. ECHILD also closes PID signaling because the child status + /// has already been consumed by an observer or ownership was lost. + static func childIsTerminalOrAlreadyReaped(_ pid: pid_t) -> Bool { + while true { + var info = siginfo_t() + let result = Darwin.waitid(P_PID, id_t(pid), &info, WEXITED | WNOHANG | WNOWAIT) + if result == 0 { + return info.si_pid == pid + } + if errno == EINTR { + continue + } + return errno == ECHILD + } + } + + /// Async convenience wrapper over the callback-based sole-reaper primitive. static func reapChildStatus( pid: pid_t, onReaped: @escaping @Sendable () -> Void = {} ) async throws -> ProcessExitStatus { try await withCheckedThrowingContinuation { continuation in - blockingReapQueue.async { - var status: Int32 = 0 - while true { - let result = waitpid(pid, &status, 0) - if result == pid { - let exitStatus = decodeWaitStatus(status) - onReaped() - continuation.resume(returning: exitStatus) - return - } - if result == -1, errno == EINTR { - continue - } - if result == -1, errno == ECHILD { - continuation.resume( - throwing: ProcessTerminationError.childOwnershipLost(pid: pid) - ) - return - } - let message = String(cString: strerror(errno)) - continuation.resume(throwing: ProcessTerminationError.waitFailed(message)) - return + observeChildStatus(pid: pid) { result in + if case .success = result { + onReaped() } + continuation.resume(with: result) } } } diff --git a/Sources/RepoPrompt/Infrastructure/VCS/GitService.swift b/Sources/RepoPrompt/Infrastructure/VCS/GitService.swift index 23436ae1a..17bf62ddf 100644 --- a/Sources/RepoPrompt/Infrastructure/VCS/GitService.swift +++ b/Sources/RepoPrompt/Infrastructure/VCS/GitService.swift @@ -7207,8 +7207,8 @@ actor GitService { } } - // Blocking waitpid runs on ProcessTermination's dedicated - // queue. This task remains the sole reaping authority. + // ProcessTermination retains cancellation-independent ownership + // until the child-exit source performs the sole destructive reap. Task.detached(priority: .userInitiated) { let reapOutcome: Result let reapRequiresGroupCleanup: Bool @@ -7602,8 +7602,8 @@ actor GitService { } } - // Blocking waitpid runs on ProcessTermination's dedicated - // queue. This task remains the sole reaping authority. + // ProcessTermination retains cancellation-independent ownership + // until the child-exit source performs the sole destructive reap. Task.detached(priority: .userInitiated) { let reapOutcome: Result let reapRequiresGroupCleanup: Bool diff --git a/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift b/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift index c5368c8be..90751439e 100644 --- a/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift +++ b/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift @@ -1,4 +1,5 @@ import Darwin +import Dispatch import Foundation @testable import RepoPromptApp import XCTest @@ -98,6 +99,70 @@ final class ProcessTerminationExitStatusTests: XCTestCase { } } + func testBlockedOutcomePublicationDoesNotDelayAnotherChildReap() async throws { + let gate = BlockedOutcomePublicationGate() + let cleanup = BlockedOutcomePublicationCleanup(gate: gate) + addTeardownBlock { + await cleanup.run() + } + let deadline = ProcessTerminationTestDeadline(timeout: 5) + + let childA = try ProcessLauncher.spawn( + command: "/bin/sh", + arguments: ["-c", "exit 17"], + environment: [:], + workingDirectory: nil + ) + let observerA = ChildProcessExitObserver( + pid: childA.pid, + beforePublishingOutcome: { gate.hold($0) } + ) + await cleanup.track(childA, observer: observerA) + closeFixtureHandles(childA) + + guard await gate.waitUntilHolding(timeout: deadline.remaining) else { + XCTFail("Timed out waiting for child A's outcome publication gate") + return + } + XCTAssertNil( + observerA.signalRootProcessFamilyIfUnreaped( + processGroupID: childA.processGroupID, + signal: 0 + ) + ) + let heldOutcomeBeforeChildB = await observerA.wait(timeout: 0) + XCTAssertNil(heldOutcomeBeforeChildB) + + let childB = try ProcessLauncher.spawn( + command: "/bin/sh", + arguments: ["-c", "exit 23"], + environment: [:], + workingDirectory: nil + ) + let observerB = ChildProcessExitObserver(pid: childB.pid) + await cleanup.track(childB, observer: observerB) + closeFixtureHandles(childB) + + guard let outcomeB = await observerB.wait(timeout: deadline.remaining) else { + XCTFail("Timed out waiting for child B while child A publication remained gated") + return + } + XCTAssertEqual(outcomeB, .exited(.exited(code: 23))) + assertAlreadyReaped(childB.pid) + let heldOutcomeAfterChildB = await observerA.wait(timeout: 0) + XCTAssertNil(heldOutcomeAfterChildB) + + gate.release() + guard let outcomeA = await observerA.wait(timeout: deadline.remaining) else { + XCTFail("Timed out waiting for child A after releasing its publication gate") + return + } + XCTAssertEqual(outcomeA, .exited(.exited(code: 17))) + assertAlreadyReaped(childA.pid) + + await cleanup.run() + } + func testChildProcessExitObserverSharesOneDetailedReap() async throws { let spawned = try ProcessLauncher.spawn( command: "/bin/sh", @@ -121,6 +186,31 @@ final class ProcessTerminationExitStatusTests: XCTestCase { } } + func testTerminalChildProbeDoesNotConsumeExitStatus() async throws { + let spawned = try ProcessLauncher.spawn( + command: "/bin/sh", + arguments: ["-c", "exit 31"], + environment: [:], + workingDirectory: nil + ) + spawned.stdin?.closeFile() + + let deadline = Date().addingTimeInterval(2) + var observedTerminalStatus = false + while Date() < deadline { + if ProcessTermination.childIsTerminalOrAlreadyReaped(spawned.pid) { + observedTerminalStatus = true + break + } + try await Task.sleep(nanoseconds: 10_000_000) + } + + let status = try await ProcessTermination.reapChildStatus(pid: spawned.pid) + XCTAssertTrue(observedTerminalStatus) + XCTAssertEqual(status, .exited(code: 31)) + XCTAssertTrue(ProcessTermination.childIsTerminalOrAlreadyReaped(spawned.pid)) + } + func testObservedTerminationEscalatesBeforeGroupOnlyCleanup() async throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("ProcessTerminationExitStatusTests-\(UUID().uuidString)", isDirectory: true) @@ -238,6 +328,119 @@ final class ProcessTerminationExitStatusTests: XCTestCase { } return kill(pid, 0) == -1 && errno == ESRCH } + + private func closeFixtureHandles(_ spawned: SpawnedProcess) { + spawned.stdin?.closeFile() + spawned.stdout.closeFile() + spawned.stderr.closeFile() + } + + private func assertAlreadyReaped( + _ pid: pid_t, + file: StaticString = #filePath, + line: UInt = #line + ) { + var status: Int32 = 0 + errno = 0 + let result = Darwin.waitpid(pid, &status, WNOHANG) + let waitError = errno + XCTAssertEqual(result, -1, file: file, line: line) + XCTAssertEqual(waitError, ECHILD, file: file, line: line) + } +} + +private struct ProcessTerminationTestDeadline { + private let expiration: TimeInterval + + init(timeout: TimeInterval) { + expiration = ProcessInfo.processInfo.systemUptime + max(timeout, 0) + } + + var remaining: TimeInterval { + max(0, expiration - ProcessInfo.processInfo.systemUptime) + } +} + +private final class BlockedOutcomePublicationGate: @unchecked Sendable { + private let lock = NSLock() + private let holdingSemaphore = DispatchSemaphore(value: 0) + private let releaseSemaphore = DispatchSemaphore(value: 0) + private var released = false + + func hold(_: ChildProcessExitObserver.Outcome) { + lock.lock() + let shouldWait = !released + lock.unlock() + holdingSemaphore.signal() + if shouldWait { + releaseSemaphore.wait() + } + } + + func waitUntilHolding(timeout: TimeInterval) async -> Bool { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { [holdingSemaphore] in + continuation.resume( + returning: holdingSemaphore.wait(timeout: .now() + max(timeout, 0)) == .success + ) + } + } + } + + func release() { + lock.lock() + guard !released else { + lock.unlock() + return + } + released = true + lock.unlock() + releaseSemaphore.signal() + } +} + +private actor BlockedOutcomePublicationCleanup { + private struct ObservedChild { + let spawned: SpawnedProcess + let observer: ChildProcessExitObserver + } + + private let gate: BlockedOutcomePublicationGate + private var children: [ObservedChild] = [] + private var didRun = false + + init(gate: BlockedOutcomePublicationGate) { + self.gate = gate + } + + func track(_ spawned: SpawnedProcess, observer: ChildProcessExitObserver) { + children.append(ObservedChild(spawned: spawned, observer: observer)) + } + + func run() async { + guard !didRun else { return } + didRun = true + gate.release() + + let ownedChildren = children + children.removeAll() + for child in ownedChildren { + child.spawned.stdin?.closeFile() + child.spawned.stdout.closeFile() + child.spawned.stderr.closeFile() + } + for child in ownedChildren { + if await child.observer.wait(timeout: 0) == nil { + _ = child.observer.signalRootProcessFamilyIfUnreaped( + processGroupID: child.spawned.processGroupID, + signal: SIGKILL + ) + } + } + for child in ownedChildren { + _ = await child.observer.wait(timeout: 1) + } + } } private actor ObservedProcessFixtureCleanup { diff --git a/Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift b/Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift index 4f442f6b9..3c89529b9 100644 --- a/Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift +++ b/Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift @@ -53,6 +53,7 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { stderrReleaseURL: stderrReleaseURL ) let expectedPIDEvents = ExpectedAgentPIDEventRecorder() + let outcomePublicationGate = ChildExitOutcomePublicationGate() let registrar = CodexAppServerClient.ExpectedAgentPIDRegistrar( register: { pid, clientName, runID in await expectedPIDEvents.recordRegister(pid: pid, clientName: clientName, runID: runID) @@ -65,8 +66,18 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { executable: executable, launchDirectory: directory, timeout: 5, + processExitObserverFactory: { pid in + ChildProcessExitObserver( + pid: pid, + beforePublishingOutcome: { outcomePublicationGate.hold($0) } + ) + }, expectedAgentPIDRegistrar: registrar ) + addTeardownBlock { + outcomePublicationGate.release() + await client.stop() + } await client.setExpectedAgentPIDRegistration(.init(clientName: "test-client", runID: UUID())) let startupCompletion = CompletionFlag() let startup = Task { @@ -78,7 +89,33 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { throw error } } - try await waitUntil(timeout: 2) { + let deadline = CodexProcessExitTestDeadline(timeout: 5) + guard await outcomePublicationGate.waitUntilHolding(timeout: deadline.remaining) else { + let debugProcessID = await client.debugProcessID() + let observerPresent = await client.debugProcessExitObserver() != nil + let terminalProbe = debugProcessID.map { + ProcessTermination.childIsTerminalOrAlreadyReaped($0) + } + let terminalObserverJoinCount = await client.debugTerminalObserverJoinCount() + throw WaitUntilError.timedOut( + "child exit outcome publication gate " + + "(debugProcessID: \(String(describing: debugProcessID)), " + + "observerPresent: \(observerPresent), " + + "terminalProbe: \(String(describing: terminalProbe)), " + + "terminalObserverJoinCount: \(terminalObserverJoinCount))" + ) + } + let heldObserverValue = await client.debugProcessExitObserver() + let heldObserver = try XCTUnwrap(heldObserverValue) + XCTAssertNil(heldObserver.signalRootProcessFamilyIfUnreaped(processGroupID: nil, signal: 0)) + try await waitUntil("terminal observer join after settlement timeout", timeout: deadline.remaining) { + await client.debugTerminalObserverJoinCount() == 1 + } + let startupCompletedWhileOutcomeWasHeld = await startupCompletion.isComplete + XCTAssertFalse(startupCompletedWhileOutcomeWasHeld) + outcomePublicationGate.release() + + try await waitUntil("typed exit-23 transport claim", timeout: deadline.remaining) { guard case .observedProcessExit(status: .exited(code: 23)) = await client.debugLastTransportTerminationReason() else { @@ -86,7 +123,7 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { } return true } - try await waitUntil(timeout: 1) { + try await waitUntil("expected PID clear", timeout: deadline.remaining) { await expectedPIDEvents.clearCount == 1 } let registrationCount = await expectedPIDEvents.registerCount @@ -126,6 +163,28 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { XCTAssertEqual(clearCount, 1) } + func testStartupStdoutEOFWhileRootLivesKeepsGenericFailure() async throws { + let directory = try makeTemporaryDirectory() + let executable = try makeLiveAfterStdoutEOFServer(in: directory) + let client = try await makeClient(executable: executable, launchDirectory: directory, timeout: 5) + addTeardownBlock { + await client.stop() + } + + do { + try await client.startIfNeeded() + XCTFail("The stdout-closed fixture must fail startup") + } catch CodexAppServerClient.ClientError.processNotRunning { + // The root is still live at EOF, so no typed exit exists to preserve. + } catch { + XCTFail("Expected generic processNotRunning, got \(error)") + } + + let terminationReason = await client.debugLastTransportTerminationReason() + XCTAssertEqual(terminationReason, .stdoutEOF) + await client.stop() + } + func testStartupSignalExitKeepsSignalSemanticsAndOmitsEmptyStderr() async throws { let directory = try makeTemporaryDirectory() let executable = try makeEarlyExitServer( @@ -224,6 +283,9 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { let recordURL = directory.appendingPathComponent("requests.jsonl") let executable = try makePersistentServer(in: directory, recordURL: recordURL) let client = try await makeClient(executable: executable, launchDirectory: directory, timeout: 5) + addTeardownBlock { + await client.stop() + } try await client.startIfNeeded() let generation = await client.debugTransportGeneration() let invalidLine = Data("not-json".utf8) @@ -231,7 +293,7 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { for _ in 0 ... CodexAppServerClient.debugMaxDecodeRecoveryAttemptsPerGeneration() { await client.debugIngestRawStdoutLine(invalidLine) } - try await waitUntil(timeout: 2) { + try await waitUntil("decode recovery teardown", timeout: 2) { await !(client.debugIsProcessRunning()) } @@ -319,8 +381,14 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { timeout: 5, processSpawnPreparation: { await spawnPreparation.prepare() } ) + let restartTaskCleanup = ThrowingTaskCleanup() + addTeardownBlock { + await spawnPreparation.release() + await client.stop() + await restartTaskCleanup.finish() + } try await client.startIfNeeded() - try await waitUntil(timeout: 2) { + try await waitUntil("initial spawn count", timeout: 2) { (try? String(contentsOf: spawnCountURL, encoding: .utf8)) == "1" } await client.stop() @@ -328,7 +396,8 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { let restart = Task { try await client.startIfNeeded() } - try await waitUntil(timeout: 2) { + await restartTaskCleanup.track(restart) + try await waitUntil("restart preparation gate", timeout: 2) { await spawnPreparation.isBlocked } await client.stop() @@ -397,10 +466,14 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { launchDirectory: URL, timeout: TimeInterval, processSpawnPreparation: @escaping @Sendable () async throws -> Void = {}, + processExitObserverFactory: @escaping @Sendable (pid_t) -> ChildProcessExitObserver = { + ChildProcessExitObserver(pid: $0) + }, expectedAgentPIDRegistrar: CodexAppServerClient.ExpectedAgentPIDRegistrar = .serverNetworkManager ) async throws -> CodexAppServerClient { let client = CodexAppServerClient( processSpawnPreparation: processSpawnPreparation, + processExitObserverFactory: processExitObserverFactory, expectedAgentPIDRegistrar: expectedAgentPIDRegistrar ) await client.updateConfig(.init( @@ -493,6 +566,22 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { return try writeExecutable(script, to: executable) } + private func makeLiveAfterStdoutEOFServer(in directory: URL) throws -> URL { + let executable = directory.appendingPathComponent("live-after-stdout-eof-codex") + let script = """ + #!/usr/bin/env python3 + import os + import sys + import time + + sys.stdin.readline() + os.close(1) + while True: + time.sleep(1) + """ + return try writeExecutable(script, to: executable) + } + private func makePersistentServer( in directory: URL, recordURL: URL, @@ -564,7 +653,18 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { XCTFail("Timed out waiting for \(method)") } + private enum WaitUntilError: LocalizedError { + case timedOut(String) + + var errorDescription: String? { + switch self { + case let .timedOut(label): "Timed out waiting for \(label)" + } + } + } + private func waitUntil( + _ label: String, timeout: TimeInterval, condition: @escaping () async -> Bool ) async throws { @@ -573,7 +673,70 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { if await condition() { return } try await Task.sleep(nanoseconds: 10_000_000) } - XCTFail("Timed out waiting for condition") + throw WaitUntilError.timedOut(label) + } +} + +private struct CodexProcessExitTestDeadline { + private let expiration: TimeInterval + + init(timeout: TimeInterval) { + expiration = ProcessInfo.processInfo.systemUptime + max(timeout, 0) + } + + var remaining: TimeInterval { + max(0, expiration - ProcessInfo.processInfo.systemUptime) + } +} + +private final class ChildExitOutcomePublicationGate: @unchecked Sendable { + private let lock = NSLock() + private let holdingSemaphore = DispatchSemaphore(value: 0) + private let releaseSemaphore = DispatchSemaphore(value: 0) + private var released = false + + func hold(_: ChildProcessExitObserver.Outcome) { + lock.lock() + let shouldWait = !released + lock.unlock() + holdingSemaphore.signal() + if shouldWait { + releaseSemaphore.wait() + } + } + + func waitUntilHolding(timeout: TimeInterval) async -> Bool { + let timeout = DispatchTime.now() + max(timeout, 0) + return await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { [holdingSemaphore] in + continuation.resume(returning: holdingSemaphore.wait(timeout: timeout) == .success) + } + } + } + + func release() { + lock.lock() + guard !released else { + lock.unlock() + return + } + released = true + lock.unlock() + releaseSemaphore.signal() + } +} + +private actor ThrowingTaskCleanup { + private var task: Task? + + func track(_ task: Task) { + self.task = task + } + + func finish() async { + guard let task else { return } + _ = try? await task.value + self.task = nil } } From 0303d79610ec452712c87a7f38906ccc5259909b Mon Sep 17 00:00:00 2001 From: MP Date: Thu, 16 Jul 2026 18:32:59 +0800 Subject: [PATCH 06/21] fix: harden Codex startup lifecycle boundaries Fence root signaling before the sole destructive reap, retry transient wait failures, and keep TERM-to-KILL cleanup bounded. Align default launch cwd, fully retire failed controller state, and canonicalize primary repository promotion with deterministic regression coverage. --- .../Fixtures/test-suite-contract-ledger.tsv | 12 +- .../Codex/CodexAgentModeCoordinator.swift | 49 ++- .../AppServer/CodexAppServerClient.swift | 4 +- .../AgentMCPStartWorktreeCoordinator.swift | 4 +- .../Process/CLIProcessConfiguration.swift | 6 +- .../Process/ChildProcessExitObserver.swift | 103 +++++- .../Process/ProcessTermination.swift | 68 +++- .../Infrastructure/VCS/GitService.swift | 4 +- .../ProcessTerminationExitStatusTests.swift | 292 +++++++++++++++++- ...CodexAppServerClientProcessExitTests.swift | 49 ++- .../MCP/AgentRunWorktreeStartTests.swift | 51 ++- 11 files changed, 593 insertions(+), 49 deletions(-) diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index 5afc7f50b..69fe987ab 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -383,13 +383,13 @@ root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentRunStartInheritsRoutedW root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentRunStartInheritsRoutedWorktreeFromFormerMCPParentWithoutControlContext root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentRunStartInheritsRoutedWorktreeFromFormerMCPParentWithoutControlContext MCP agent.start.worktree-inheritance.former_mcp_parent routed_source,sticky_mcp_origin,nil_control_context,default_inheritance,effective_path behavioral_regression root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture A routed child start from a hydrated parent with persisted worktree bindings, sticky isMCPOriginated=true, and no live MCP control context inherits the binding in the start response and effective workspace path. A former MCP-created parent later driven from the UI could permanently fail inherited child starts with an unavailable-control-context error despite valid persisted bindings. 0.750000 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Issue #383 QA follow-up regression for sticky MCP-origin sessions after control context release. root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartExplicitWorktreeIDOverridesInheritedBindingAcrossRunExploreAndExplicitTab root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentStartExplicitWorktreeIDOverridesInheritedBindingAcrossRunExploreAndExplicitTab MCP agent.start.explicit-worktree-override existing_worktree_id,created_worktree,inherit_true,inherit_false,explore,explicit_tab behavioral_regression root_swiftpm routine 5 AgentRunWorktreeStartLifecycleFixture Explicit existing-worktree and app-created Agent Run routes contain only the requested binding regardless of inherit_worktree, Explore preserves its explicit binding, explicit-tab routing also suppresses the captured parent binding without creating a parent relationship, and spawned children retain their expected parent relationships. Automatic or frozen-source inheritance could leak a parent checkout alongside or instead of an explicitly selected or created child worktree on Run, Explore, or explicit-tab routes. 3.177000 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture consolidated_replacement 0 Renamed from root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartExplicitWorktreeIDOverridesInheritedBindingAcrossRunAndExplore; retains replacements root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentRunStartExplicitWorktreeIDOverridesInheritanceSetting and root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentExploreStartExplicitWorktreeIDOverridesInheritedBindingBeforeProviderStart; strengthened with explicit-tab override precedence. root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartWorktreeSelectorRejectsSecondaryGitRepoWhenPrimaryRootIsNonGit root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentStartWorktreeSelectorRejectsSecondaryGitRepoWhenPrimaryRootIsNonGit MCP agent.start.worktree-selector.non-git-primary-rejection non_git_primary,secondary_git,worktree_id,fail_closed,no_target_mutation behavioral_regression root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture A shorthand worktree ID targeting a secondary Git repository is rejected by the primary-root runtime boundary when the declared primary workspace root is non-Git, without creating a target tab. Fallback repository resolution could pair a secondary Git checkout with the non-Git primary logical root and bypass the primary-only provider runtime restriction. suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Primary-root fail-closed regression for implicit worktree repository selection. -root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartWorktreeSelectorsDefaultToWorkspacePrimaryRepoWhenRootsLoadedOutOfOrder root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentStartWorktreeSelectorsDefaultToWorkspacePrimaryRepoWhenRootsLoadedOutOfOrder MCP agent.start.worktree-selector.primary-root-default multi_root,inverse_root_order,primary_workspace,worktree_id,branch_selector,implicit_repo_root,explicit_repo_root behavioral_regression root_swiftpm routine 5 AgentRunWorktreeStartLifecycleFixture A deterministic inverse-order root-ref seam selects the declared primary root, then worktree ID and branch selectors bind that primary repository and logical root for both implicit and explicit primary-root selection. Using file-index load order as the implicit repository, or mishandling an explicit primary selector, could reject a valid primary-root worktree before provider startup or associate it with the wrong logical root. suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Multi-root shorthand selector regression for issue #554. +root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartWorktreeSelectorsDefaultToWorkspacePrimaryRepoWhenRootsLoadedOutOfOrder root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentStartWorktreeSelectorsDefaultToWorkspacePrimaryRepoWhenRootsLoadedOutOfOrder MCP agent.start.worktree-selector.primary-root-default multi_root,inverse_root_order,primary_workspace,symlink_alias,worktree_id,branch_selector,implicit_repo_root,explicit_repo_root behavioral_regression root_swiftpm routine 6 AgentRunWorktreeStartLifecycleFixture Direct and symlink-aliased declared-primary paths promote the primary root ahead of inverse discovery order, then worktree ID and branch selectors bind that repository and logical root for implicit and explicit selection. Using file-index load order or lexical path identity could reject a valid primary-root worktree before provider startup or associate it with the wrong logical root. suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Multi-root shorthand selector regression for issue #554. root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartWorktreeSelectorsRejectCrossRepositoryMatchesForImplicitAndExplicitPrimaryRoot root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentStartWorktreeSelectorsRejectCrossRepositoryMatchesForImplicitAndExplicitPrimaryRoot MCP agent.start.worktree-selector.repository-authority multi_root,worktree_id,branch_selector,implicit_repo_root,explicit_repo_root,cross_repository,fail_closed,no_target_mutation behavioral_regression root_swiftpm routine 4 AgentRunWorktreeStartLifecycleFixture Worktree ID and branch selectors targeting a secondary repository are rejected for both implicit and explicit primary-root selection, leaving tab and session counts unchanged. A globally unique foreign worktree selector could resolve across workspace repositories and be persisted under the selected primary logical root. suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Existing-worktree selector repository-authority regression for issue #554. root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionMaterializesSessionWorktreeWithoutCodemapWork root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testBindingTransitionMaterializesSessionWorktreeWithoutCodemapWork MCP agent.worktree.binding_transition.zero_codemap_materialization initial_binding,watcher_ownership,idempotent_materialization,zero_codemap_work,last_release_cleanup deterministic_integration root_swiftpm routine 4 AgentRunWorktreeStartLifecycleFixture Initial binding materializes one watched physical root with zero legacy scans, modern demands, freezes, or initialization tasks; repeated projection reuses the owner/root without recrawl; unbind releases the final claim. Worktree binding could eagerly start codemap work, duplicate catalog ownership, or leak the final root claim. 0.064500 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Milestone D replaces eager session-worktree codemap initialization with neutral materialization and zero-work assertions. root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionValidatesAndMaterializesChangedSecondaryBindingWithoutRestartingPrimaryIdentity root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testBindingTransitionValidatesAndMaterializesChangedSecondaryBindingWithoutRestartingPrimaryIdentity MCP agent.worktree.binding-transition.secondary behavioral_regression root_swiftpm routine 2 AgentRunWorktreeStartLifecycleFixture Unavailable secondary binding rejects atomically; valid changed secondary materializes both physical roots without restarting provider or Codex identity high 0.804500 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture consolidated_replacement 0 replaces root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionMaterializesChangedSecondaryBindingWithoutRestartingUnchangedPrimaryIdentity; root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionRejectsUnavailableSecondaryBindingEvenWhenPrimaryIdentityIsUnchanged root/RepoPromptTests.AgentRunWorktreeStartTests/testCanonicalAgentRunReviewSourceStagesBindsToFreshWorktreeChildAndCleansUp root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCanonicalAgentRunReviewSourceStagesBindsToFreshWorktreeChildAndCleansUp MCP agent.oracle_review.canonical_run_binding launch_snapshot,exact_run,cleanup deterministic_lifecycle_integration root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture Canonical source packaging stages for an exact fresh worktree child run with an independent target binding and disappears on control deactivation. A stale or sibling child run could consume source-owned review packaging. 0.107000 suite_owned_temp_roots;awaited_window_teardown WindowStatesManager AgentRunWorktreeStartLifecycleFixture retain 0 Issue #264 exact-ID ledger reconciliation root/RepoPromptTests.AgentRunWorktreeStartTests/testChildSessionWorktreeBindingInheritanceCanBeOptedOut root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testChildSessionWorktreeBindingInheritanceCanBeOptedOut MCP unreviewed unreviewed root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture unreviewed unreviewed 0.003000 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain_pending_review 0 initial census source line 76 -root/RepoPromptTests.AgentRunWorktreeStartTests/testCodexControllerFailsClosedWhenInstalledLaunchRootBecomesMalformed root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCodexControllerFailsClosedWhenInstalledLaunchRootBecomesMalformed MCP agent.worktree.codex-controller-resolution-failure-teardown codex_launch_execution_split lifecycle_regression root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture;ReplacementIdentityFakeCodexController Installed controller identity fields, event task, run ID, reconnect state, and shutdown count after a selected logical root becomes malformed A workspace resolution failure could leave the installed Codex controller reusable and its event stream alive 0.020000 suite_owned_temp_roots;suite_owned_immutable_git_seed GlobalSettingsStore.globalCodeMapsDisabled;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 issue #554 WI-1 fail-closed installed-controller teardown +root/RepoPromptTests.AgentRunWorktreeStartTests/testCodexControllerFailsClosedWhenInstalledLaunchRootBecomesMalformed root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCodexControllerFailsClosedWhenInstalledLaunchRootBecomesMalformed MCP agent.worktree.codex-controller-resolution-failure-teardown codex_launch_execution_split,approval,follow_up,computer_use,liveness,tool_tracking lifecycle_regression root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture;ReplacementIdentityFakeCodexController An installed controller whose selected logical root becomes malformed is shut down and loses its identity, event task, approval, queued follow-up, computer-use activation, native-tool liveness, and tool tracking while preserving the failed run identity. A workspace resolution failure could leave controller-scoped tasks or interactions active after the controller becomes unusable. 0.020000 suite_owned_temp_roots;suite_owned_immutable_git_seed GlobalSettingsStore.globalCodeMapsDisabled;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 issue #554 WI-1 fail-closed installed-controller teardown root/RepoPromptTests.AgentRunWorktreeStartTests/testCodexControllerReplacementKeysOnExecutionChangeAndSurvivesSecondaryBindingChanges root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCodexControllerReplacementKeysOnExecutionChangeAndSurvivesSecondaryBindingChanges MCP agent.worktree.codex-controller-replacement-key codex_launch_execution_split lifecycle_regression root_swiftpm routine 4 AgentRunWorktreeStartLifecycleFixture;ReplacementIdentityFakeCodexController Factory invocation pairs and controller ObjectIdentifier across ensure passes for creation, unchanged pair, secondary-binding change, and execution-directory change Codex controller could be recycled on secondary-binding churn or kept alive across an execution-directory change 0.088000 suite_owned_temp_roots;suite_owned_immutable_git_seed GlobalSettingsStore.globalCodeMapsDisabled;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 issue #554 WI-1 Codex launch/execution path pair coverage root/RepoPromptTests.AgentRunWorktreeStartTests/testCodexControllerReplacementKeysOnLaunchOnlyChange root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCodexControllerReplacementKeysOnLaunchOnlyChange MCP agent.worktree.codex-controller-replacement-launch-only codex_launch_execution_split lifecycle_regression root_swiftpm routine 2 AgentRunWorktreeStartLifecycleFixture;ReplacementIdentityFakeCodexController Factory invocation pairs and controller ObjectIdentifier across a logical-root-only binding change with unchanged execution directory A launch-directory-only change could reuse a Codex controller whose app-server was launched from the wrong root 0.038000 suite_owned_temp_roots;suite_owned_immutable_git_seed GlobalSettingsStore.globalCodeMapsDisabled;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 issue #554 WI-1 Codex launch/execution path pair coverage root/RepoPromptTests.AgentRunWorktreeStartTests/testCodexRuntimeWorkspacePathsFailsClosedOnMalformedSelectedLogicalRoot root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCodexRuntimeWorkspacePathsFailsClosedOnMalformedSelectedLogicalRoot MCP agent.worktree.codex-launch-root-validation codex_launch_execution_split protocol_negative root_swiftpm routine 3 AgentRunWorktreeStartLifecycleFixture Typed emptyLogicalRoot and launchDirectoryUnavailable errors plus the nil/nil fallback pair A malformed selected logical root could reach process spawn as an opaque launch failure instead of a pre-startup typed error 0.001000 suite_owned_temp_roots;suite_owned_immutable_git_seed AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 issue #554 WI-1 Codex launch/execution path pair coverage @@ -675,11 +675,14 @@ root/RepoPromptTests.CLIProcessRunnerLifecycleTests/testStreamingProcessLifecycl root/RepoPromptTests.CLIProcessRunnerLifecycleTests/testStreamingProcessTerminationCallbackRunsAfterRunnerCancellation root Tests/RepoPromptTests/AI/CLIProcessRunnerLifecycleTests.swift RepoPromptTests.CLIProcessRunnerLifecycleTests testStreamingProcessTerminationCallbackRunsAfterRunnerCancellation AI unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.066000 unreviewed retain_pending_review 0 initial census source line 40 root/RepoPromptTests.CLIProcessRunnerLifecycleTests/testTerminateAndReapUsesSpawnedProcessGroupForDirectProviderStyleProcess root Tests/RepoPromptTests/AI/CLIProcessRunnerLifecycleTests.swift RepoPromptTests.CLIProcessRunnerLifecycleTests testTerminateAndReapUsesSpawnedProcessGroupForDirectProviderStyleProcess AI ai.cli_process_runner.terminate_and_reap_uses_spawned_process_group process_group,direct_provider,descendant_reaping subprocess_lifecycle_regression root_swiftpm routine 1 temporary_pid_marker Terminating a directly spawned provider-style shell reaps the same-process-group descendant recorded by the marker file. Direct provider launches could terminate only the parent and leak child processes. subprocess;filesystem;async_wait test_case retain 0 origin/main reconciliation after commits 5c1504f1/dc79e7a4: reviewed direct-provider process-group reap coverage. root/RepoPromptTests.ProcessTerminationExitStatusTests/testBlockedOutcomePublicationDoesNotDelayAnotherChildReap root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testBlockedOutcomePublicationDoesNotDelayAnotherChildReap AI ai.process_termination.child_exit_observer.publication_isolation waitpid,dispatch_source,observer,publication_gate,echild subprocess_lifecycle_regression root_swiftpm routine 2 BlockedOutcomePublicationGate Child A closes its PID-signaling window while outcome publication is gated; child B still publishes exited(23) and is already reaped, then releasing A publishes exited(17) and confirms its reap. A blocked diagnostic publication hook could stall the serial registry, delay unrelated Codex exit evidence, or reopen competing PID signaling and waitpid ownership. subprocess;async_wait gate+actor_cleanup+addTeardownBlock retain 0 Worktree-bound Codex startup REVIEW remediation: deterministic publication isolation and sole-reaper coverage. -root/RepoPromptTests.ProcessTerminationExitStatusTests/testBlockingReapChildStatusPreservesExitAndSignalSemantics root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testBlockingReapChildStatusPreservesExitAndSignalSemantics AI ai.process_termination.blocking_reap_status waitpid,blocking_reap,exit_status,signal,reap_callback subprocess_lifecycle_regression root_swiftpm routine 2 GitProcessLifecycleTarget A dedicated off-executor blocking waitpid reaps normal and signaled children with exact status semantics and marks lifecycle state before await resumes. Git completion could poll unnecessarily, diverge from exit decoding, or expose a post-reap lifecycle signaling gap. 0.100000 subprocess;async_wait test_case retain 0 Oracle P1 process-launch follow-up: event-driven sole-reaper and synchronous post-waitpid hook coverage. +root/RepoPromptTests.ProcessTerminationExitStatusTests/testBlockingReapChildStatusPreservesExitAndSignalSemantics root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testBlockingReapChildStatusPreservesExitAndSignalSemantics AI ai.process_termination.blocking_reap_status waitpid,blocking_reap,exit_status,signal,pre_reap_callback subprocess_lifecycle_regression root_swiftpm routine 2 GitProcessLifecycleTarget A dedicated off-executor registry reaps normal and signaled children with exact status semantics and closes lifecycle signaling before its destructive wait. Git completion could poll unnecessarily, diverge from exit decoding, or expose a PID-signaling gap at the destructive reap boundary. 0.100000 subprocess;async_wait test_case retain 0 Event-driven sole-reaper and synchronous pre-reap lifecycle-fence coverage. root/RepoPromptTests.ProcessTerminationExitStatusTests/testBlockingReapChildStatusTreatsECHILDAsOwnershipError root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testBlockingReapChildStatusTreatsECHILDAsOwnershipError AI ai.process_termination.blocking_reap_echild_ownership_error waitpid,blocking_reap,echild,ownership subprocess_lifecycle_regression root_swiftpm routine 1 A second sole-reaper wait for an already reaped child throws childOwnershipLost instead of fabricating exited(0). ECHILD could be misreported as a successful zero exit and conceal competing waitpid ownership. 0.050000 subprocess;async_wait test_case retain 0 GPT-5.6 Sol Ultra P1 follow-up: ECHILD ownership-error coverage. +root/RepoPromptTests.ProcessTerminationExitStatusTests/testChildProcessExitObserverClosesSignalingBeforeDestructiveReap root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testChildProcessExitObserverClosesSignalingBeforeDestructiveReap AI ai.process_termination.child_exit_observer.pre_reap_signal_fence waitid,wnowait,waitpid,observer,pid_reuse,signal_window subprocess_lifecycle_regression root_swiftpm routine 1 BlockedPreReapGate The pre-reap gate proves the exited child remains waitable while root PID/group signaling is already rejected, then releasing the gate publishes exited(29) and leaves ECHILD for any second reap. Closing signaling only after waitpid could allow a concurrent signal to target a reused PID or process group. subprocess;async_wait;dispatch_wait gate+actor_cleanup+addTeardownBlock retain 0 Kernel-boundary sole-reaper signal-safety contract. root/RepoPromptTests.ProcessTerminationExitStatusTests/testChildProcessExitObserverSharesOneDetailedReap root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testChildProcessExitObserverSharesOneDetailedReap AI ai.process_termination.child_exit_observer.shared_sole_reap waitpid,observer,exit_status,echild,sole_reaper subprocess_lifecycle_regression root_swiftpm routine 1 ChildProcessExitObserver Repeated observer waits share exited(19), and a later direct reap reports childOwnershipLost. Multiple lifecycle consumers could perform competing destructive waits or fabricate exit evidence. subprocess;async_wait test_case retain 0 WI-2 sole-reaper observation coverage. root/RepoPromptTests.ProcessTerminationExitStatusTests/testDecodeWaitStatusPreservesExitSignalAndFallbackSemantics root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testDecodeWaitStatusPreservesExitSignalAndFallbackSemantics AI ai.process_termination.detailed_status_decode waitpid,exit_status,signal,fallback deterministic_unit root_swiftpm fast 4 Raw waitpid statuses decode to exited/uncaughtSignal cases whose normalized, terminationStatus, and terminationReason projections all agree. Detailed decoding could diverge from the historical 128+signal normalization or from NSTask terminationStatus parity. 0.000000 test_case retain 0 posix_spawn git launch migration: new behavioral coverage for ProcessLauncher-based GitService spawning. root/RepoPromptTests.ProcessTerminationExitStatusTests/testObservedTerminationEscalatesBeforeGroupOnlyCleanup root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testObservedTerminationEscalatesBeforeGroupOnlyCleanup AI ai.process_termination.observed_term_kill_group_cleanup sigterm,sigkill,process_group,root_reap,descendant_cleanup subprocess_lifecycle_regression root_swiftpm routine 1 sigterm_ignoring_process_family Observer-aware teardown escalates a TERM-resistant family to KILL, observes the root signal, removes the descendant, and leaves ECHILD for any second reap. Teardown could race a second waitpid, signal a reused root PID, or leak descendants. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 observer-aware teardown coverage. +root/RepoPromptTests.ProcessTerminationExitStatusTests/testObservedTerminationRetriesWaitFailureAndEscalates root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testObservedTerminationRetriesWaitFailureAndEscalates AI ai.process_termination.observer_wait_failure_retry wait_failed,observer,retry,sigterm,sigkill,sole_reaper subprocess_lifecycle_regression root_swiftpm routine 1 FailOnceChildStatusObserver;sigterm_ignoring_process One injected wait failure returns observation to the registry, preserves TERM-to-KILL authority, publishes uncaughtSignal(SIGKILL), and leaves ECHILD for any second reap. Treating a wait failure as a completed reap could suppress escalation while the root remains alive or strand its exit status. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 Retryable sole-reaper failure contract. +root/RepoPromptTests.ProcessTerminationExitStatusTests/testObservedTerminationReturnsAfterBoundedKillGraceWhileOutcomePublicationIsBlocked root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testObservedTerminationReturnsAfterBoundedKillGraceWhileOutcomePublicationIsBlocked AI ai.process_termination.observed_cleanup_bounded_tail sigterm,sigkill,observer,publication_gate,bounded_cleanup,descendant_cleanup subprocess_lifecycle_regression root_swiftpm routine 1 BlockedOutcomePublicationGate;ProcessTerminationCompletionProbe;descendant_process Observed teardown returns after bounded TERM and KILL grace and removes a TERM-resistant descendant while root outcome publication remains blocked; releasing publication later yields exited(37) from the sole reaper. Stop, replacement, or shutdown could hang indefinitely after the configured KILL grace expires or leak descendants when only outcome publication is delayed. subprocess;filesystem;async_wait;dispatch_wait gate+actor_cleanup+addTeardownBlock retain 0 Bounded cleanup-tail and descendant-cleanup contract. root/RepoPromptTests.ProcessTerminationExitStatusTests/testTerminalChildProbeDoesNotConsumeExitStatus root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testTerminalChildProbeDoesNotConsumeExitStatus AI ai.process_termination.child_status.nondestructive_terminal_probe waitid,wnowait,waitpid,exit_status,echild subprocess_lifecycle_regression root_swiftpm routine 2 The terminal-child probe detects both a zombie and an already-reaped child while leaving exited(31) available to the sole destructive reaper. A diagnostic probe could consume typed exit evidence or mistake an already-reaped root for a live process. subprocess;async_wait test_case retain 0 Worktree-bound Codex startup EOF arbitration regression coverage. root/RepoPromptTests.ProcessTerminationExitStatusTests/testWaitForTerminationStatusReportsRealChildExitAndSignal root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testWaitForTerminationStatusReportsRealChildExitAndSignal AI ai.process_termination.detailed_status_reap waitpid,exit_status,signal,reaping subprocess_lifecycle_regression root_swiftpm routine 2 Real children reaped through waitForTerminationStatus report exited(3) and uncaughtSignal(SIGKILL) with normalized parity. The detailed wait variant could mis-decode real waitpid statuses or diverge from the normalized API for signal exits. 0.300000 subprocess;async_wait test_case retain 0 posix_spawn git launch migration: new behavioral coverage for ProcessLauncher-based GitService spawning. root/RepoPromptTests.ChatHistoryJSONOnlyTests/testCurrentChatSessionSaveLoadUsesCEWorkspaceRoot root Tests/RepoPromptTests/ChatHistoryJSONOnlyTests.swift RepoPromptTests.ChatHistoryJSONOnlyTests testCurrentChatSessionSaveLoadUsesCEWorkspaceRoot Root unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.001500 unreviewed retain_pending_review 0 initial census source line 5 @@ -1037,9 +1040,10 @@ root/RepoPromptTests.CodexAppServerClientProcessExitTests/testDecodeRecoveryExha root/RepoPromptTests.CodexAppServerClientProcessExitTests/testDeinitLeavesReapOwnershipWithObserver root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testDeinitLeavesReapOwnershipWithObserver AgentMode/Codex codex.app_server.exit_observer.deinit_reap deinit,sole_reaper,zombie_prevention subprocess_lifecycle_regression root_swiftpm routine 1 persistent_codex_fixture Dropping the client leaves the detached observer as the only reaper and waitpid reaches ECHILD without the test consuming status. Client deinit could cancel reap ownership and strand a zombie app-server process. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 sole-reaper lifecycle coverage. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testExplicitStopWinsOverObservedTermination root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testExplicitStopWinsOverObservedTermination AgentMode/Codex codex.app_server.lifecycle.explicit_stop_precedence explicit_stop,observer_exit,first_claim subprocess_lifecycle_regression root_swiftpm routine 1 persistent_codex_fixture A blocked request receives processNotRunning and the generation retains explicitStop after TERM produces an observed exit. Intentional stop could be mislabeled as an unexpected process crash. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 lifecycle-precedence coverage. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testListModelsRetriesTypedProcessExitOnceOnFreshProcess root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testListModelsRetriesTypedProcessExitOnceOnFreshProcess AgentMode/Codex codex.app_server.model_list.typed_exit_retry model_list,process_exit,retry,fresh_process subprocess_lifecycle_regression root_swiftpm routine 1 exit_then_model_codex_fixture A typed processExited failure during model/list receives one fresh-process retry and returns the successful replacement process model response. Typed exit recovery could regress from the established EOF recovery behavior and leave model discovery unavailable after a transient app-server exit. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 typed-exit model-list recovery coverage. +root/RepoPromptTests.CodexAppServerClientProcessExitTests/testNilLaunchDirectoryUsesCLIProcessConfigurationDefaultInExitEvidence root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testNilLaunchDirectoryUsesCLIProcessConfigurationDefaultInExitEvidence AgentMode/Codex codex.app_server.launch_directory.default launch_directory,temp_directory,process_cwd,exit_evidence subprocess_lifecycle_regression root_swiftpm routine 1 working_directory_exit_codex_fixture A nil process launch directory records the CLIProcessConfiguration temp default, the child reports the same canonical cwd, and typed exit evidence preserves exited(41). The app-server could inherit the app cwd while diagnostics claim a different launch contract. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 App-server launch-directory authority contract. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStaleObservedExitCannotMutateReplacementGeneration root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStaleObservedExitCannotMutateReplacementGeneration AgentMode/Codex codex.app_server.lifecycle.stale_generation_exit_rejection generation,observer_identity,replacement async_concurrency_lifecycle root_swiftpm routine 1 persistent_codex_fixture A delivered old-generation exit leaves the replacement PID, generation, running state, and termination reason unchanged. Late observer callbacks could poison a replacement transport. subprocess;filesystem;actor test_case+fixture_cleanup retain 0 WI-2 generation-safety coverage. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStopDuringRestartPreparationPreventsSpawnAfterReturn root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStopDuringRestartPreparationPreventsSpawnAfterReturn AgentMode/Codex codex.app_server.lifecycle.stop_restart_spawn_fence stop,restart,startup_cancellation,pre_spawn,termination_retirement async_concurrency_lifecycle root_swiftpm routine 1 persistent_codex_fixture,ProcessSpawnPreparationGate A stop during blocked restart preparation returns only after revoking startup authority; releasing preparation afterward cannot produce a second process spawn or live transport. A settled teardown task could let stop return without cancelling a restart, allowing an app-server to spawn and survive after shutdown. subprocess;filesystem;actor;async_wait test_case+fixture_cleanup retain 0 WI-2 restart pre-spawn shutdown regression coverage. -root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStartupEOFReturnsTypedExitWithSettledBoundedStderr root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStartupEOFReturnsTypedExitWithSettledBoundedStderr AgentMode/Codex codex.app_server.process_exit.typed_stderr_evidence stdout_eof,exit_status,terminal_probe,observer_join,root_signal_window,stderr_tail,truncation,descendant_stderr,family_cleanup,first_claim,shared_completion subprocess_lifecycle_regression root_swiftpm routine 4 early_exit_codex_fixture,ChildExitOutcomePublicationGate A post-waitpid publication gate holds beyond the diagnostic window while root-PID signaling is already closed, forcing the non-destructive terminal probe and sole-observer join before processExited returns the exact final 8 KiB raw stderr suffix; a losing stop remains pending until the winning settlement completes. Startup EOF could signal a reaped or reused PID, collapse a real exit into processNotRunning, consume status with a second reap, snapshot incomplete descendant-held stderr, or let duplicate lifecycle callers return before teardown completes. subprocess;filesystem;async_wait;dispatch_wait test_case+fixture_cleanup retain 0 Deterministic kernel-boundary signal closure, terminal-probe arbitration, bounded startup diagnostics, descendant cleanup, and shared-completion coverage. +root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStartupEOFReturnsTypedExitWithSettledBoundedStderr root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStartupEOFReturnsTypedExitWithSettledBoundedStderr AgentMode/Codex codex.app_server.process_exit.typed_stderr_evidence stdout_eof,exit_status,terminal_probe,observer_join,root_signal_window,stderr_tail,truncation,descendant_stderr,family_cleanup,first_claim,shared_completion subprocess_lifecycle_regression root_swiftpm routine 1 early_exit_codex_fixture,ChildExitOutcomePublicationGate A publication gate holds beyond the diagnostic window after pre-reap root-PID signaling closure, forcing the non-destructive terminal probe and sole-observer join before processExited returns the exact final 8 KiB raw stderr suffix; a losing stop remains pending until the winning settlement completes. Startup EOF could signal a reaped or reused PID, collapse a real exit into processNotRunning, consume status with a second reap, snapshot incomplete descendant-held stderr, or let duplicate lifecycle callers return before teardown completes. subprocess;filesystem;async_wait;dispatch_wait test_case+fixture_cleanup retain 0 Deterministic kernel-boundary signal closure, terminal-probe arbitration, bounded startup diagnostics, descendant cleanup, and shared-completion coverage. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStartupSignalExitKeepsSignalSemanticsAndOmitsEmptyStderr root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStartupSignalExitKeepsSignalSemanticsAndOmitsEmptyStderr AgentMode/Codex codex.app_server.process_exit.signal_evidence signal,empty_stderr,typed_exit subprocess_lifecycle_regression root_swiftpm routine 1 early_exit_codex_fixture A SIGKILL startup exit remains uncaughtSignal(SIGKILL), retains empty raw stderr, and omits stderr from rendered diagnostics. Signal exits could be normalized into ambiguous integer codes or render empty diagnostic noise. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 typed exit-evidence coverage. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStartupStdoutEOFWhileRootLivesKeepsGenericFailure root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStartupStdoutEOFWhileRootLivesKeepsGenericFailure AgentMode/Codex codex.app_server.process_exit.live_root_stdout_eof stdout_eof,live_root,generic_failure,termination subprocess_lifecycle_regression root_swiftpm routine 1 live_after_stdout_eof_codex_fixture EOF from a still-running root retains processNotRunning with stdoutEOF and proceeds through normal termination instead of waiting indefinitely for typed exit. Terminal-exit arbitration could join a live root observer forever or fabricate typed evidence when only stdout closed. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 Worktree-bound Codex startup EOF arbitration converse coverage. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStderrCaptureRetainsExactRawSuffixAtEveryBoundary root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStderrCaptureRetainsExactRawSuffixAtEveryBoundary AgentMode/Codex codex.app_server.stderr_tail.byte_boundaries stderr,byte_cap,truncation,invalid_utf8,multichunk deterministic_unit root_swiftpm fast 6 CodexProcessStderrCapture Empty, 1, 8191, 8192, 8193, and multi-chunk invalid-UTF-8 inputs retain the exact expected suffix and truncation bit. Boundary drift could lose bytes, overrun the cap, or decode evidence before rendering. test_case retain 0 WI-2 raw stderr capture coverage. diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift index 3490ad554..fe5cf1204 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift @@ -3689,6 +3689,36 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { viewModel?.scheduleSave(for: session.tabID) } + private func retireCodexControllerAfterWorkspaceResolutionFailure( + session: AgentModeViewModel.TabSession, + controller: any CodexSessionControlling + ) async { + cancelCodexThreadNameSync(for: session.tabID) + cancelCodexIdleShutdown(for: session.tabID) + cancelCodexTransportClosedFallback(for: session.tabID) + stopBashLivenessTask(for: session.tabID) + stopCodexStallWatchdog(for: session.tabID) + session.codexEventTask?.cancel() + session.codexEventTask = nil + session.codexEventTaskRunID = nil + session.codexLastEventAt = nil + session.pendingCommandRunningFlushTask?.cancel() + session.pendingCommandRunningFlushTask = nil + session.pendingCommandRunningByKey.removeAll() + abandonCodexFallbackQueue( + session: session, + reason: "Codex queued follow-up was cancelled because the workspace root became unavailable." + ) + resetTrackedCodexTurns(session) + session.pendingCodexComputerUseActivation = nil + clearCodexPendingInteractions(in: session) + resetCodexWatchdogState(session) + clearCodexNativeToolLiveness(session) + clearCodexControllerInstanceState(for: session) + await controller.shutdown() + await stopCodexToolTrackingAndWait(for: session) + } + func ensureCodexNativeSession( session: AgentModeViewModel.TabSession, policyAlreadyInstalled: Bool = false, @@ -3739,11 +3769,10 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { let controllerToShutdown = session.codexController await failCodexStartupForWorkspaceResolution(session: session, error: error) if let controllerToShutdown { - session.codexEventTask?.cancel() - session.codexEventTask = nil - session.codexEventTaskRunID = nil - clearCodexControllerInstanceState(for: session) - await controllerToShutdown.shutdown() + await retireCodexControllerAfterWorkspaceResolutionFailure( + session: session, + controller: controllerToShutdown + ) } return } @@ -7092,6 +7121,16 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { ) return shouldRetryCodexStartWithoutResume(existingRef: existingRef, error: error) } + + @_spi(TestSupport) + public func test_hasCodexToolTracking(for tabID: UUID) -> Bool { + toolTrackingByTabID[tabID] != nil + } + + @_spi(TestSupport) + public func test_installCodexToolTrackingPlaceholder(for tabID: UUID) { + toolTrackingByTabID[tabID] = AgentToolTrackingController() + } #endif private func enqueueCommandExecutionRunningUpdate( diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift index e1d7d5767..bba3c623d 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift @@ -1269,7 +1269,9 @@ actor CodexAppServerClient { featurePolicy: config.processFeaturePolicy ) let args = processOverrides + ["app-server"] - let launchDirectory = config.processLaunchDirectory ?? FileManager.default.currentDirectoryPath + let launchDirectory = CLIProcessConfiguration.resolvedWorkingDirectory( + config.processLaunchDirectory + ) try await processSpawnPreparation() try Task.checkCancellation() let spawned = try ProcessLauncher.spawn( diff --git a/Sources/RepoPrompt/Infrastructure/MCP/Agent/AgentMCPStartWorktreeCoordinator.swift b/Sources/RepoPrompt/Infrastructure/MCP/Agent/AgentMCPStartWorktreeCoordinator.swift index 3dc029a43..44171ed95 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/Agent/AgentMCPStartWorktreeCoordinator.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/Agent/AgentMCPStartWorktreeCoordinator.swift @@ -729,9 +729,9 @@ struct AgentMCPStartWorktreeCoordinator { guard let primaryRoot else { return visibleRoots } - let primaryPath = ((primaryRoot as NSString).expandingTildeInPath as NSString).standardizingPath + let primaryPath = GitRepoRootAuthorization.canonicalPath(primaryRoot) guard let primaryIndex = visibleRoots.firstIndex(where: { - ($0.standardizedFullPath as NSString).standardizingPath == primaryPath + GitRepoRootAuthorization.canonicalPath($0.standardizedFullPath) == primaryPath }) else { return visibleRoots } diff --git a/Sources/RepoPrompt/Infrastructure/Process/CLIProcessConfiguration.swift b/Sources/RepoPrompt/Infrastructure/Process/CLIProcessConfiguration.swift index 5bc016537..7be33bece 100644 --- a/Sources/RepoPrompt/Infrastructure/Process/CLIProcessConfiguration.swift +++ b/Sources/RepoPrompt/Infrastructure/Process/CLIProcessConfiguration.swift @@ -1,6 +1,10 @@ import Foundation struct CLIProcessConfiguration { + static func resolvedWorkingDirectory(_ workingDirectory: String?) -> String { + workingDirectory ?? FileManager.default.temporaryDirectory.path + } + var command: String /// Working directory for the CLI process. Defaults to temp directory to avoid macOS security popups. var workingDirectory: String @@ -35,7 +39,7 @@ struct CLIProcessConfiguration { logStdinSampleBytes: Int = 0 ) { self.command = command - self.workingDirectory = workingDirectory ?? FileManager.default.temporaryDirectory.path + self.workingDirectory = Self.resolvedWorkingDirectory(workingDirectory) self.environment = environment self.additionalPaths = additionalPaths self.commandSuffix = commandSuffix diff --git a/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift b/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift index c13febfba..28754df83 100644 --- a/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift +++ b/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift @@ -6,6 +6,12 @@ import Foundation /// Callers may wait repeatedly, but only the callback-based observation invokes /// `waitpid` through `ProcessTermination.observeChildStatus`. final class ChildProcessExitObserver: @unchecked Sendable { + typealias StatusObserver = @Sendable ( + _ pid: pid_t, + _ beforeReap: @escaping @Sendable () -> Void, + _ completion: @escaping @Sendable (Result) -> Void + ) -> Void + enum Outcome: Equatable { case exited(ProcessExitStatus) case failed(ProcessTerminationError) @@ -17,12 +23,20 @@ final class ChildProcessExitObserver: @unchecked Sendable { private var outcome: Outcome? private var waiters: [UUID: CheckedContinuation] = [:] - func markRootReaped() { + func closeRootSignalingBeforeReap() { lock.lock() rootReaped = true lock.unlock() } + func reopenRootSignalingAfterWaitFailure() -> Bool { + lock.lock() + defer { lock.unlock() } + guard outcome == nil else { return false } + rootReaped = false + return true + } + func finish(with outcome: Outcome) { lock.lock() guard self.outcome == nil else { @@ -72,6 +86,12 @@ final class ChildProcessExitObserver: @unchecked Sendable { return operation() } + var isRootSignalingClosed: Bool { + lock.lock() + defer { lock.unlock() } + return rootReaped + } + private func expireWaiter(_ waiterID: UUID) { lock.lock() let continuation = waiters.removeValue(forKey: waiterID) @@ -90,34 +110,39 @@ final class ChildProcessExitObserver: @unchecked Sendable { init( pid: pid_t, - beforePublishingOutcome: @escaping @Sendable (Outcome) -> Void = { _ in } + beforePublishingOutcome: @escaping @Sendable (Outcome) -> Void = { _ in }, + afterClosingRootSignalingBeforeReap: @escaping @Sendable () -> Void = {}, + statusObserver: @escaping StatusObserver = { pid, beforeReap, completion in + ProcessTermination.observeChildStatus( + pid: pid, + beforeReap: beforeReap, + completion: completion + ) + } ) { self.pid = pid let state = State() self.state = state - ProcessTermination.observeChildStatus(pid: pid) { result in - state.markRootReaped() - let outcome: Outcome = switch result { - case let .success(status): .exited(status) - case let .failure(error): .failed(error) - } - // Publication hooks may deliberately block for diagnostics. Keep - // them off the serial reaper registry so unrelated children can - // continue closing their ownership windows and publishing exits. - Self.outcomePublicationQueue.async { - beforePublishingOutcome(outcome) - state.finish(with: outcome) - } - } + Self.beginObservation( + pid: pid, + state: state, + beforePublishingOutcome: beforePublishingOutcome, + afterClosingRootSignalingBeforeReap: afterClosingRootSignalingBeforeReap, + statusObserver: statusObserver + ) } func wait(timeout: TimeInterval? = nil) async -> Outcome? { await state.wait(timeout: timeout) } + var isRootSignalingClosed: Bool { + state.isRootSignalingClosed + } + /// Signals only while this observer still owns an unreaped root PID. The - /// callback closes this lock-protected window immediately after waitpid, + /// registry closes this lock-protected window before its destructive wait, /// before diagnostic outcome publication can block or change executors. func signalRootProcessFamilyIfUnreaped( processGroupID: pid_t?, @@ -133,4 +158,48 @@ final class ChildProcessExitObserver: @unchecked Sendable { ) } } + + private static func beginObservation( + pid: pid_t, + state: State, + beforePublishingOutcome: @escaping @Sendable (Outcome) -> Void, + afterClosingRootSignalingBeforeReap: @escaping @Sendable () -> Void, + statusObserver: @escaping StatusObserver + ) { + statusObserver( + pid, + { + state.closeRootSignalingBeforeReap() + afterClosingRootSignalingBeforeReap() + }, + { result in + if case .failure(.waitFailed) = result, + state.reopenRootSignalingAfterWaitFailure() + { + outcomePublicationQueue.asyncAfter(deadline: .now() + .milliseconds(10)) { + beginObservation( + pid: pid, + state: state, + beforePublishingOutcome: beforePublishingOutcome, + afterClosingRootSignalingBeforeReap: afterClosingRootSignalingBeforeReap, + statusObserver: statusObserver + ) + } + return + } + + let outcome: Outcome = switch result { + case let .success(status): .exited(status) + case let .failure(error): .failed(error) + } + // Publication hooks may deliberately block for diagnostics. Keep + // them off the serial reaper registry so unrelated child exits can + // continue closing their ownership windows and publishing exits. + outcomePublicationQueue.async { + beforePublishingOutcome(outcome) + state.finish(with: outcome) + } + } + ) + } } diff --git a/Sources/RepoPrompt/Infrastructure/Process/ProcessTermination.swift b/Sources/RepoPrompt/Infrastructure/Process/ProcessTermination.swift index 42a7fc688..0c0f4dce1 100644 --- a/Sources/RepoPrompt/Infrastructure/Process/ProcessTermination.swift +++ b/Sources/RepoPrompt/Infrastructure/Process/ProcessTermination.swift @@ -77,15 +77,18 @@ private final class ChildStatusReaperRegistry: @unchecked Sendable { private final class Entry { let token: UUID let source: any DispatchSourceProcess + let beforeReap: @Sendable () -> Void let completion: @Sendable (Result) -> Void init( token: UUID, source: any DispatchSourceProcess, + beforeReap: @escaping @Sendable () -> Void, completion: @escaping @Sendable (Result) -> Void ) { self.token = token self.source = source + self.beforeReap = beforeReap self.completion = completion } } @@ -98,6 +101,7 @@ private final class ChildStatusReaperRegistry: @unchecked Sendable { func observe( pid: pid_t, + beforeReap: @escaping @Sendable () -> Void, completion: @escaping @Sendable (Result) -> Void ) { queue.async { [weak self] in @@ -113,7 +117,12 @@ private final class ChildStatusReaperRegistry: @unchecked Sendable { eventMask: .exit, queue: queue ) - let entry = Entry(token: token, source: source, completion: completion) + let entry = Entry( + token: token, + source: source, + beforeReap: beforeReap, + completion: completion + ) entries[pid] = entry source.setRegistrationHandler { [weak self] in @@ -140,8 +149,31 @@ private final class ChildStatusReaperRegistry: @unchecked Sendable { } private func reap(pid: pid_t, token: UUID, mode: ReapMode) { - guard entries[pid]?.token == token else { return } + guard let entry = entries[pid], entry.token == token else { return } + while true { + var info = siginfo_t() + let probeResult = Darwin.waitid(P_PID, id_t(pid), &info, WEXITED | WNOHANG | WNOWAIT) + if probeResult == 0 { + guard info.si_pid == pid else { return } + break + } + if errno == EINTR { + continue + } + if errno == ECHILD { + complete(pid: pid, token: token, result: .failure(.childOwnershipLost(pid: pid))) + return + } + complete( + pid: pid, + token: token, + result: .failure(.waitFailed(String(cString: strerror(errno)))) + ) + return + } + + entry.beforeReap() var status: Int32 = 0 while true { let result = waitpid(pid, &status, mode.waitOptions) @@ -466,14 +498,19 @@ enum ProcessTermination { } /// Registers one cancellation-independent direct-child observation. The - /// completion runs synchronously on the serial process-wide registry queue - /// after the destructive reap. It must close PID-signaling state before it - /// returns and return promptly so unrelated child exits can be processed. + /// `beforeReap` runs synchronously after a non-destructive terminal probe + /// and before the sole destructive wait. The completion must return promptly + /// so unrelated child exits can be processed. static func observeChildStatus( pid: pid_t, + beforeReap: @escaping @Sendable () -> Void = {}, completion: @escaping @Sendable (Result) -> Void ) { - ChildStatusReaperRegistry.shared.observe(pid: pid, completion: completion) + ChildStatusReaperRegistry.shared.observe( + pid: pid, + beforeReap: beforeReap, + completion: completion + ) } /// Checks child terminal state without consuming the status owned by the @@ -496,13 +533,10 @@ enum ProcessTermination { /// Async convenience wrapper over the callback-based sole-reaper primitive. static func reapChildStatus( pid: pid_t, - onReaped: @escaping @Sendable () -> Void = {} + beforeReap: @escaping @Sendable () -> Void = {} ) async throws -> ProcessExitStatus { try await withCheckedThrowingContinuation { continuation in - observeChildStatus(pid: pid) { result in - if case .success = result { - onReaped() - } + observeChildStatus(pid: pid, beforeReap: beforeReap) { result in continuation.resume(with: result) } } @@ -582,8 +616,16 @@ enum ProcessTermination { logger: logger ) if await observer.wait(timeout: killGrace) == nil { - logger("Process \(observer.pid) has not settled after SIGKILL; awaiting the sole reaper") - _ = await observer.wait() + logger("Process \(observer.pid) has not settled after bounded SIGKILL cleanup; sole-reaper observation remains active") + if observer.isRootSignalingClosed { + await terminateProcessGroupAfterRootReap( + processGroupID: processGroupID, + sigtermGrace: termGrace, + sigkillGrace: killGrace, + logger: logger + ) + } + return } } diff --git a/Sources/RepoPrompt/Infrastructure/VCS/GitService.swift b/Sources/RepoPrompt/Infrastructure/VCS/GitService.swift index 17bf62ddf..6389adc5b 100644 --- a/Sources/RepoPrompt/Infrastructure/VCS/GitService.swift +++ b/Sources/RepoPrompt/Infrastructure/VCS/GitService.swift @@ -7216,7 +7216,7 @@ actor GitService { reapOutcome = try await .success( ProcessTermination.reapChildStatus( pid: spawned.pid, - onReaped: { target.markTerminated() } + beforeReap: { target.markTerminated() } ) ) reapRequiresGroupCleanup = false @@ -7611,7 +7611,7 @@ actor GitService { reapOutcome = try await .success( ProcessTermination.reapChildStatus( pid: spawned.pid, - onReaped: { target.markTerminated() } + beforeReap: { target.markTerminated() } ) ) reapRequiresGroupCleanup = false diff --git a/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift b/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift index 90751439e..633e16306 100644 --- a/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift +++ b/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift @@ -59,7 +59,7 @@ final class ProcessTerminationExitStatusTests: XCTestCase { ) let exitStatus = try await ProcessTermination.reapChildStatus( pid: exiting.pid, - onReaped: { target.markTerminated() } + beforeReap: { target.markTerminated() } ) XCTAssertEqual(exitStatus, .exited(code: 7)) XCTAssertFalse(target.isRunning) @@ -163,6 +163,48 @@ final class ProcessTerminationExitStatusTests: XCTestCase { await cleanup.run() } + func testChildProcessExitObserverClosesSignalingBeforeDestructiveReap() async throws { + let gate = BlockedPreReapGate() + let spawned = try ProcessLauncher.spawn( + command: "/bin/sh", + arguments: ["-c", "exit 29"], + environment: [:], + workingDirectory: nil + ) + let observer = ChildProcessExitObserver( + pid: spawned.pid, + afterClosingRootSignalingBeforeReap: { gate.hold() } + ) + let cleanup = ObservedProcessFixtureCleanup( + observer: observer, + processGroupID: spawned.processGroupID + ) + addTeardownBlock { + gate.release() + await cleanup.run() + } + closeFixtureHandles(spawned) + + let reachedPreReapGate = await gate.waitUntilHolding(timeout: 2) + XCTAssertTrue(reachedPreReapGate) + XCTAssertNil( + observer.signalRootProcessFamilyIfUnreaped( + processGroupID: spawned.processGroupID, + signal: 0 + ) + ) + assertTerminalChildStillAwaitingReap(spawned.pid) + + gate.release() + let outcome = await observer.wait(timeout: 2) + XCTAssertEqual( + outcome, + .exited(.exited(code: 29)) + ) + assertAlreadyReaped(spawned.pid) + await cleanup.run() + } + func testChildProcessExitObserverSharesOneDetailedReap() async throws { let spawned = try ProcessLauncher.spawn( command: "/bin/sh", @@ -272,6 +314,143 @@ final class ProcessTerminationExitStatusTests: XCTestCase { } } + func testObservedTerminationReturnsAfterBoundedKillGraceWhileOutcomePublicationIsBlocked() async throws { + let gate = BlockedOutcomePublicationGate() + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("ProcessTerminationExitStatusTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + temporaryDirectories.append(directory) + let childPIDURL = directory.appendingPathComponent("child.pid") + let script = """ + import os + import signal + import time + + child = os.fork() + if child == 0: + signal.signal(signal.SIGTERM, signal.SIG_IGN) + while True: + time.sleep(1) + with open(\(String(reflecting: childPIDURL.path)), "w", encoding="utf-8") as handle: + handle.write(str(child)) + handle.flush() + os.fsync(handle.fileno()) + os._exit(37) + """ + let spawned = try ProcessLauncher.spawn( + command: "/usr/bin/python3", + arguments: ["-c", script], + environment: [:], + workingDirectory: directory.path + ) + let observer = ChildProcessExitObserver( + pid: spawned.pid, + beforePublishingOutcome: { gate.hold($0) } + ) + let cleanup = ObservedProcessFixtureCleanup( + observer: observer, + processGroupID: spawned.processGroupID + ) + addTeardownBlock { + gate.release() + await cleanup.run() + } + closeFixtureHandles(spawned) + let reachedPublicationGate = await gate.waitUntilHolding(timeout: 2) + XCTAssertTrue(reachedPublicationGate) + let observedChildPID = await waitForPID(at: childPIDURL) + let childPID = try XCTUnwrap(observedChildPID) + + let completion = ProcessTerminationCompletionProbe() + let termination = Task { + await ProcessTermination.terminateObservedProcessFamily( + observer: observer, + processGroupID: spawned.processGroupID, + sigtermGrace: 0.02, + sigkillGrace: 0.02 + ) + await completion.markComplete() + } + let returnedWhilePublicationWasBlocked = await completion.waitUntilComplete(timeout: 0.5) + let descendantIsAbsent = await waitUntilProcessIsAbsent(childPID) + gate.release() + await termination.value + + XCTAssertTrue(returnedWhilePublicationWasBlocked) + XCTAssertTrue(descendantIsAbsent) + let outcome = await observer.wait(timeout: 2) + XCTAssertEqual( + outcome, + .exited(.exited(code: 37)) + ) + assertAlreadyReaped(spawned.pid) + await cleanup.run() + } + + func testObservedTerminationRetriesWaitFailureAndEscalates() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("ProcessTerminationExitStatusTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + temporaryDirectories.append(directory) + let readyURL = directory.appendingPathComponent("ready") + let script = """ + import os + import signal + import time + + signal.signal(signal.SIGTERM, signal.SIG_IGN) + with open(\(String(reflecting: readyURL.path)), "w", encoding="utf-8") as handle: + handle.write("ready") + handle.flush() + os.fsync(handle.fileno()) + while True: + time.sleep(1) + """ + let spawned = try ProcessLauncher.spawn( + command: "/usr/bin/python3", + arguments: ["-c", script], + environment: [:], + workingDirectory: directory.path + ) + let statusObserver = FailOnceChildStatusObserver() + let observer = ChildProcessExitObserver( + pid: spawned.pid, + statusObserver: { pid, beforeReap, completion in + statusObserver.observe( + pid: pid, + beforeReap: beforeReap, + completion: completion + ) + } + ) + let cleanup = ObservedProcessFixtureCleanup( + observer: observer, + processGroupID: spawned.processGroupID + ) + addTeardownBlock { + await cleanup.run() + } + closeFixtureHandles(spawned) + let fixtureIsReady = await waitUntilFileExists(readyURL, timeout: 2) + XCTAssertTrue(fixtureIsReady) + + await ProcessTermination.terminateObservedProcessFamily( + observer: observer, + processGroupID: spawned.processGroupID, + sigtermGrace: 0.05, + sigkillGrace: 0.5 + ) + + XCTAssertGreaterThanOrEqual(statusObserver.observationCount, 2) + let outcome = await observer.wait(timeout: 0) + XCTAssertEqual( + outcome, + .exited(.uncaughtSignal(signal: SIGKILL)) + ) + assertAlreadyReaped(spawned.pid) + await cleanup.run() + } + func testWaitForTerminationStatusReportsRealChildExitAndSignal() async throws { let exiting = try ProcessLauncher.spawn( command: "/bin/sh", @@ -329,6 +508,17 @@ final class ProcessTerminationExitStatusTests: XCTestCase { return kill(pid, 0) == -1 && errno == ESRCH } + private func waitUntilFileExists(_ url: URL, timeout: TimeInterval) async -> Bool { + let deadline = ProcessInfo.processInfo.systemUptime + timeout + while ProcessInfo.processInfo.systemUptime < deadline { + if FileManager.default.fileExists(atPath: url.path) { + return true + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return FileManager.default.fileExists(atPath: url.path) + } + private func closeFixtureHandles(_ spawned: SpawnedProcess) { spawned.stdin?.closeFile() spawned.stdout.closeFile() @@ -347,6 +537,18 @@ final class ProcessTerminationExitStatusTests: XCTestCase { XCTAssertEqual(result, -1, file: file, line: line) XCTAssertEqual(waitError, ECHILD, file: file, line: line) } + + private func assertTerminalChildStillAwaitingReap( + _ pid: pid_t, + file: StaticString = #filePath, + line: UInt = #line + ) { + var info = siginfo_t() + errno = 0 + let result = Darwin.waitid(P_PID, id_t(pid), &info, WEXITED | WNOHANG | WNOWAIT) + XCTAssertEqual(result, 0, file: file, line: line) + XCTAssertEqual(info.si_pid, pid, file: file, line: line) + } } private struct ProcessTerminationTestDeadline { @@ -399,6 +601,94 @@ private final class BlockedOutcomePublicationGate: @unchecked Sendable { } } +private final class BlockedPreReapGate: @unchecked Sendable { + private let lock = NSLock() + private let holdingSemaphore = DispatchSemaphore(value: 0) + private let releaseSemaphore = DispatchSemaphore(value: 0) + private var released = false + + func hold() { + lock.lock() + let shouldWait = !released + lock.unlock() + holdingSemaphore.signal() + if shouldWait { + releaseSemaphore.wait() + } + } + + func waitUntilHolding(timeout: TimeInterval) async -> Bool { + let timeout = DispatchTime.now() + max(timeout, 0) + return await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { [holdingSemaphore] in + continuation.resume(returning: holdingSemaphore.wait(timeout: timeout) == .success) + } + } + } + + func release() { + lock.lock() + guard !released else { + lock.unlock() + return + } + released = true + lock.unlock() + releaseSemaphore.signal() + } +} + +private final class FailOnceChildStatusObserver: @unchecked Sendable { + private let lock = NSLock() + private var attempts = 0 + + var observationCount: Int { + lock.lock() + defer { lock.unlock() } + return attempts + } + + func observe( + pid: pid_t, + beforeReap: @escaping @Sendable () -> Void, + completion: @escaping @Sendable (Result) -> Void + ) { + lock.lock() + attempts += 1 + let attempt = attempts + lock.unlock() + + if attempt == 1 { + completion(.failure(.waitFailed("injected transient wait failure"))) + return + } + ProcessTermination.observeChildStatus( + pid: pid, + beforeReap: beforeReap, + completion: completion + ) + } +} + +private actor ProcessTerminationCompletionProbe { + private var isComplete = false + + func markComplete() { + isComplete = true + } + + func waitUntilComplete(timeout: TimeInterval) async -> Bool { + let deadline = ProcessInfo.processInfo.systemUptime + timeout + while ProcessInfo.processInfo.systemUptime < deadline { + if isComplete { + return true + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return isComplete + } +} + private actor BlockedOutcomePublicationCleanup { private struct ObservedChild { let spawned: SpawnedProcess diff --git a/Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift b/Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift index 3c89529b9..fcf16f1c9 100644 --- a/Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift +++ b/Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift @@ -163,6 +163,36 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { XCTAssertEqual(clearCount, 1) } + func testNilLaunchDirectoryUsesCLIProcessConfigurationDefaultInExitEvidence() async throws { + let directory = try makeTemporaryDirectory() + let executable = try makeWorkingDirectoryExitServer(in: directory) + let client = try await makeClient( + executable: executable, + launchDirectory: nil, + timeout: 5 + ) + addTeardownBlock { + await client.stop() + } + + do { + try await client.startIfNeeded() + XCTFail("The cwd-reporting fixture must fail startup") + } catch let CodexAppServerClient.ClientError.processExited(evidence) { + let expectedDirectory = CLIProcessConfiguration.resolvedWorkingDirectory(nil) + let actualDirectory = String(decoding: evidence.stderrTail, as: UTF8.self) + XCTAssertEqual(evidence.launchDirectory, expectedDirectory) + XCTAssertEqual( + GitRepoRootAuthorization.canonicalPath(actualDirectory), + GitRepoRootAuthorization.canonicalPath(expectedDirectory) + ) + XCTAssertEqual(evidence.status, .exited(code: 41)) + XCTAssertTrue(evidence.stderrWasSettled) + } catch { + XCTFail("Expected typed processExited evidence, got \(error)") + } + } + func testStartupStdoutEOFWhileRootLivesKeepsGenericFailure() async throws { let directory = try makeTemporaryDirectory() let executable = try makeLiveAfterStdoutEOFServer(in: directory) @@ -463,7 +493,7 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { private func makeClient( executable: URL, - launchDirectory: URL, + launchDirectory: URL?, timeout: TimeInterval, processSpawnPreparation: @escaping @Sendable () async throws -> Void = {}, processExitObserverFactory: @escaping @Sendable (pid_t) -> ChildProcessExitObserver = { @@ -480,7 +510,7 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { commandName: executable.path, additionalPathHints: [], requestTimeout: timeout, - processLaunchDirectory: launchDirectory.path + processLaunchDirectory: launchDirectory?.path )) return client } @@ -582,6 +612,21 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { return try writeExecutable(script, to: executable) } + private func makeWorkingDirectoryExitServer(in directory: URL) throws -> URL { + let executable = directory.appendingPathComponent("working-directory-exit-codex") + let script = """ + #!/usr/bin/env python3 + import os + import sys + + sys.stdin.readline() + os.write(2, os.getcwd().encode("utf-8")) + os.close(1) + os._exit(41) + """ + return try writeExecutable(script, to: executable) + } + private func makePersistentServer( in directory: URL, recordURL: URL, diff --git a/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift b/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift index a0c46f528..6d70cddff 100644 --- a/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift +++ b/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift @@ -2,7 +2,7 @@ import CryptoKit import Darwin import Foundation import MCP -@testable import RepoPromptApp +@_spi(TestSupport) @testable import RepoPromptApp import XCTest private final class AgentRunWorktreeStartGitSeedRepository: @unchecked Sendable { @@ -586,6 +586,36 @@ final class AgentRunWorktreeStartTests: AgentRunWorktreeStartGitSeedTestCase { let installedRunID = try XCTUnwrap(session.runID) XCTAssertNotNil(session.codexController) XCTAssertNotNil(session.codexEventTask) + session.pendingApproval = AgentApprovalRequest( + requestID: .codex(.int(1)), + method: "item/commandExecution/requestApproval", + kind: .commandExecution, + threadID: "thread", + turnID: "turn", + itemID: "item" + ) + session.pendingCodexComputerUseActivation = .init(id: UUID(), createdAt: Date()) + session.codexPendingTurnKind = .user + session.codexFallbackPumpTask = Task { + try? await Task.sleep(nanoseconds: 5_000_000_000) + } + session.mcpFollowUpRunPending = true + let livenessKey = AgentModeViewModel.CodexNativeToolLivenessState.Key( + invocationID: UUID(), + fallbackSignature: "test" + ) + session.codexNativeToolLiveness.inFlight[livenessKey] = .init( + toolName: "bash", + startedAt: Date(), + lastSignalAt: Date(), + processID: "123" + ) + viewModel.test_codexCoordinator.test_installCodexToolTrackingPlaceholder( + for: session.tabID + ) + XCTAssertTrue( + viewModel.test_codexCoordinator.test_hasCodexToolTracking(for: session.tabID) + ) session.worktreeBindings = [makeBinding(logicalRoot: " ", worktreeRoot: worktree.path)] await viewModel.test_codexCoordinator.ensureCodexNativeSession(session: session) @@ -600,6 +630,15 @@ final class AgentRunWorktreeStartTests: AgentRunWorktreeStartGitSeedTestCase { XCTAssertNil(session.codexControllerTaskLabelKind) XCTAssertNil(session.codexControllerWorkspacePaths) XCTAssertNil(session.codexControllerFeatureState) + XCTAssertNil(session.pendingApproval) + XCTAssertNil(session.pendingCodexComputerUseActivation) + XCTAssertNil(session.codexPendingTurnKind) + XCTAssertNil(session.codexFallbackPumpTask) + XCTAssertFalse(session.mcpFollowUpRunPending) + XCTAssertTrue(session.codexNativeToolLiveness.inFlight.isEmpty) + XCTAssertFalse( + viewModel.test_codexCoordinator.test_hasCodexToolTracking(for: session.tabID) + ) XCTAssertEqual(controller.shutdownCallCount, 1) } @@ -2522,6 +2561,16 @@ final class AgentRunWorktreeStartTests: AgentRunWorktreeStartGitSeedTestCase { visibleRoots: [secondaryRef, primaryRef] ) XCTAssertEqual(discoveryRoots, [primaryRef, secondaryRef]) + let primaryAlias = primaryFixture.sandbox.appendingPathComponent("primary-repo-alias") + try FileManager.default.createSymbolicLink( + at: primaryAlias, + withDestinationURL: primaryFixture.repo + ) + let aliasDiscoveryRoots = AgentMCPStartWorktreeCoordinator.repositoryDiscoveryRoots( + primaryRoot: primaryAlias.path, + visibleRoots: [secondaryRef, primaryRef] + ) + XCTAssertEqual(aliasDiscoveryRoots, [primaryRef, secondaryRef]) let window = try await makeWindow(roots: [primaryFixture.repo, secondaryFixture.repo]) let workspace = try XCTUnwrap(window.workspaceManager.activeWorkspace) From 4b47ce1f97dc61e8ba3d51b27a22ecc2bf96724c Mon Sep 17 00:00:00 2001 From: MP Date: Thu, 16 Jul 2026 19:00:27 +0800 Subject: [PATCH 07/21] fix: bound Codex reaper wait-failure retries Persistent kernel wait failures now retry with exponential backoff from 10 ms to a 1 s ceiling instead of a fixed 10 ms loop, while the observer retains sole destructive-reap ownership until a terminal result. --- .../Fixtures/test-suite-contract-ledger.tsv | 2 + .../Process/ChildProcessExitObserver.swift | 24 ++++++- .../ProcessTerminationExitStatusTests.swift | 72 +++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index 69fe987ab..016f51e17 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -680,10 +680,12 @@ root/RepoPromptTests.ProcessTerminationExitStatusTests/testBlockingReapChildStat root/RepoPromptTests.ProcessTerminationExitStatusTests/testChildProcessExitObserverClosesSignalingBeforeDestructiveReap root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testChildProcessExitObserverClosesSignalingBeforeDestructiveReap AI ai.process_termination.child_exit_observer.pre_reap_signal_fence waitid,wnowait,waitpid,observer,pid_reuse,signal_window subprocess_lifecycle_regression root_swiftpm routine 1 BlockedPreReapGate The pre-reap gate proves the exited child remains waitable while root PID/group signaling is already rejected, then releasing the gate publishes exited(29) and leaves ECHILD for any second reap. Closing signaling only after waitpid could allow a concurrent signal to target a reused PID or process group. subprocess;async_wait;dispatch_wait gate+actor_cleanup+addTeardownBlock retain 0 Kernel-boundary sole-reaper signal-safety contract. root/RepoPromptTests.ProcessTerminationExitStatusTests/testChildProcessExitObserverSharesOneDetailedReap root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testChildProcessExitObserverSharesOneDetailedReap AI ai.process_termination.child_exit_observer.shared_sole_reap waitpid,observer,exit_status,echild,sole_reaper subprocess_lifecycle_regression root_swiftpm routine 1 ChildProcessExitObserver Repeated observer waits share exited(19), and a later direct reap reports childOwnershipLost. Multiple lifecycle consumers could perform competing destructive waits or fabricate exit evidence. subprocess;async_wait test_case retain 0 WI-2 sole-reaper observation coverage. root/RepoPromptTests.ProcessTerminationExitStatusTests/testDecodeWaitStatusPreservesExitSignalAndFallbackSemantics root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testDecodeWaitStatusPreservesExitSignalAndFallbackSemantics AI ai.process_termination.detailed_status_decode waitpid,exit_status,signal,fallback deterministic_unit root_swiftpm fast 4 Raw waitpid statuses decode to exited/uncaughtSignal cases whose normalized, terminationStatus, and terminationReason projections all agree. Detailed decoding could diverge from the historical 128+signal normalization or from NSTask terminationStatus parity. 0.000000 test_case retain 0 posix_spawn git launch migration: new behavioral coverage for ProcessLauncher-based GitService spawning. +root/RepoPromptTests.ProcessTerminationExitStatusTests/testObservedExitSurvivesRepeatedWaitFailuresWithSoleReap root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testObservedExitSurvivesRepeatedWaitFailuresWithSoleReap AI ai.process_termination.child_exit_observer.repeated_wait_failure_retry wait_failed,observer,retry,backoff,sole_reaper subprocess_lifecycle_regression root_swiftpm routine 1 FailNTimesChildStatusObserver Three injected consecutive wait failures still end in one destructive reap publishing exited(7) with ECHILD left for any second reap. Repeated transient wait failures could abandon reap ownership, strand a zombie, or publish duplicate outcomes. subprocess;async_wait test_case retain 0 Clean-branch REVIEW loop 2 P0 remediation: repeated-failure retry keeps sole-reaper ownership. root/RepoPromptTests.ProcessTerminationExitStatusTests/testObservedTerminationEscalatesBeforeGroupOnlyCleanup root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testObservedTerminationEscalatesBeforeGroupOnlyCleanup AI ai.process_termination.observed_term_kill_group_cleanup sigterm,sigkill,process_group,root_reap,descendant_cleanup subprocess_lifecycle_regression root_swiftpm routine 1 sigterm_ignoring_process_family Observer-aware teardown escalates a TERM-resistant family to KILL, observes the root signal, removes the descendant, and leaves ECHILD for any second reap. Teardown could race a second waitpid, signal a reused root PID, or leak descendants. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 observer-aware teardown coverage. root/RepoPromptTests.ProcessTerminationExitStatusTests/testObservedTerminationRetriesWaitFailureAndEscalates root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testObservedTerminationRetriesWaitFailureAndEscalates AI ai.process_termination.observer_wait_failure_retry wait_failed,observer,retry,sigterm,sigkill,sole_reaper subprocess_lifecycle_regression root_swiftpm routine 1 FailOnceChildStatusObserver;sigterm_ignoring_process One injected wait failure returns observation to the registry, preserves TERM-to-KILL authority, publishes uncaughtSignal(SIGKILL), and leaves ECHILD for any second reap. Treating a wait failure as a completed reap could suppress escalation while the root remains alive or strand its exit status. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 Retryable sole-reaper failure contract. root/RepoPromptTests.ProcessTerminationExitStatusTests/testObservedTerminationReturnsAfterBoundedKillGraceWhileOutcomePublicationIsBlocked root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testObservedTerminationReturnsAfterBoundedKillGraceWhileOutcomePublicationIsBlocked AI ai.process_termination.observed_cleanup_bounded_tail sigterm,sigkill,observer,publication_gate,bounded_cleanup,descendant_cleanup subprocess_lifecycle_regression root_swiftpm routine 1 BlockedOutcomePublicationGate;ProcessTerminationCompletionProbe;descendant_process Observed teardown returns after bounded TERM and KILL grace and removes a TERM-resistant descendant while root outcome publication remains blocked; releasing publication later yields exited(37) from the sole reaper. Stop, replacement, or shutdown could hang indefinitely after the configured KILL grace expires or leak descendants when only outcome publication is delayed. subprocess;filesystem;async_wait;dispatch_wait gate+actor_cleanup+addTeardownBlock retain 0 Bounded cleanup-tail and descendant-cleanup contract. root/RepoPromptTests.ProcessTerminationExitStatusTests/testTerminalChildProbeDoesNotConsumeExitStatus root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testTerminalChildProbeDoesNotConsumeExitStatus AI ai.process_termination.child_status.nondestructive_terminal_probe waitid,wnowait,waitpid,exit_status,echild subprocess_lifecycle_regression root_swiftpm routine 2 The terminal-child probe detects both a zombie and an already-reaped child while leaving exited(31) available to the sole destructive reaper. A diagnostic probe could consume typed exit evidence or mistake an already-reaped root for a live process. subprocess;async_wait test_case retain 0 Worktree-bound Codex startup EOF arbitration regression coverage. +root/RepoPromptTests.ProcessTerminationExitStatusTests/testWaitFailureRetryDelayDoublesFromTenMillisecondsToOneSecondCeiling root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testWaitFailureRetryDelayDoublesFromTenMillisecondsToOneSecondCeiling AI ai.process_termination.child_exit_observer.wait_failure_backoff_curve wait_failed,observer,retry,backoff deterministic_unit root_swiftpm fast 3 Retry pacing starts at 10 ms, doubles per consecutive failure, and clamps at the 1 s ceiling for all later failures. An unbounded or fixed-interval retry could spin the outcome-publication queue at high frequency during persistent kernel wait failures. 0.000000 test_case retain 0 Clean-branch REVIEW loop 2 P0 remediation: bounded wait-failure backoff curve. root/RepoPromptTests.ProcessTerminationExitStatusTests/testWaitForTerminationStatusReportsRealChildExitAndSignal root Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift RepoPromptTests.ProcessTerminationExitStatusTests testWaitForTerminationStatusReportsRealChildExitAndSignal AI ai.process_termination.detailed_status_reap waitpid,exit_status,signal,reaping subprocess_lifecycle_regression root_swiftpm routine 2 Real children reaped through waitForTerminationStatus report exited(3) and uncaughtSignal(SIGKILL) with normalized parity. The detailed wait variant could mis-decode real waitpid statuses or diverge from the normalized API for signal exits. 0.300000 subprocess;async_wait test_case retain 0 posix_spawn git launch migration: new behavioral coverage for ProcessLauncher-based GitService spawning. root/RepoPromptTests.ChatHistoryJSONOnlyTests/testCurrentChatSessionSaveLoadUsesCEWorkspaceRoot root Tests/RepoPromptTests/ChatHistoryJSONOnlyTests.swift RepoPromptTests.ChatHistoryJSONOnlyTests testCurrentChatSessionSaveLoadUsesCEWorkspaceRoot Root unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.001500 unreviewed retain_pending_review 0 initial census source line 5 root/RepoPromptTests.ChatHistoryJSONOnlyTests/testLegacyChatSessionEditPayloadsAreIgnoredOnDecodeAndOmittedOnEncode root Tests/RepoPromptTests/ChatHistoryJSONOnlyTests.swift RepoPromptTests.ChatHistoryJSONOnlyTests testLegacyChatSessionEditPayloadsAreIgnoredOnDecodeAndOmittedOnEncode Root unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 70 diff --git a/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift b/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift index 28754df83..c07d0d6a3 100644 --- a/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift +++ b/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift @@ -108,6 +108,10 @@ final class ChildProcessExitObserver: @unchecked Sendable { attributes: .concurrent ) + /// - Parameter afterClosingRootSignalingBeforeReap: Runs on the process-wide + /// reaper registry queue between signal-window closure and the destructive + /// reap; it must never block, or exit processing for every observed child + /// stalls behind it. init( pid: pid_t, beforePublishingOutcome: @escaping @Sendable (Outcome) -> Void = { _ in }, @@ -159,12 +163,23 @@ final class ChildProcessExitObserver: @unchecked Sendable { } } + /// Retry pacing for persistent wait failures: the observer never abandons + /// reap ownership, so the interval doubles from 10 ms to a 1 s ceiling to + /// bound registry and queue pressure while the failure persists. + static func waitFailureRetryDelay(consecutiveFailures: Int) -> TimeInterval { + let base: TimeInterval = 0.010 + let ceiling: TimeInterval = 1.0 + let boundedExponent = min(max(consecutiveFailures - 1, 0), 7) + return min(base * pow(2, Double(boundedExponent)), ceiling) + } + private static func beginObservation( pid: pid_t, state: State, beforePublishingOutcome: @escaping @Sendable (Outcome) -> Void, afterClosingRootSignalingBeforeReap: @escaping @Sendable () -> Void, - statusObserver: @escaping StatusObserver + statusObserver: @escaping StatusObserver, + consecutiveWaitFailures: Int = 0 ) { statusObserver( pid, @@ -176,13 +191,16 @@ final class ChildProcessExitObserver: @unchecked Sendable { if case .failure(.waitFailed) = result, state.reopenRootSignalingAfterWaitFailure() { - outcomePublicationQueue.asyncAfter(deadline: .now() + .milliseconds(10)) { + let failureCount = consecutiveWaitFailures + 1 + let retryDelay = waitFailureRetryDelay(consecutiveFailures: failureCount) + outcomePublicationQueue.asyncAfter(deadline: .now() + retryDelay) { beginObservation( pid: pid, state: state, beforePublishingOutcome: beforePublishingOutcome, afterClosingRootSignalingBeforeReap: afterClosingRootSignalingBeforeReap, - statusObserver: statusObserver + statusObserver: statusObserver, + consecutiveWaitFailures: failureCount ) } return diff --git a/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift b/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift index 633e16306..35330faee 100644 --- a/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift +++ b/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift @@ -451,6 +451,41 @@ final class ProcessTerminationExitStatusTests: XCTestCase { await cleanup.run() } + func testWaitFailureRetryDelayDoublesFromTenMillisecondsToOneSecondCeiling() { + XCTAssertEqual(ChildProcessExitObserver.waitFailureRetryDelay(consecutiveFailures: 1), 0.010, accuracy: 0.0001) + XCTAssertEqual(ChildProcessExitObserver.waitFailureRetryDelay(consecutiveFailures: 2), 0.020, accuracy: 0.0001) + XCTAssertEqual(ChildProcessExitObserver.waitFailureRetryDelay(consecutiveFailures: 3), 0.040, accuracy: 0.0001) + XCTAssertEqual(ChildProcessExitObserver.waitFailureRetryDelay(consecutiveFailures: 7), 0.640, accuracy: 0.0001) + XCTAssertEqual(ChildProcessExitObserver.waitFailureRetryDelay(consecutiveFailures: 8), 1.0, accuracy: 0.0001) + XCTAssertEqual(ChildProcessExitObserver.waitFailureRetryDelay(consecutiveFailures: 50), 1.0, accuracy: 0.0001) + } + + func testObservedExitSurvivesRepeatedWaitFailuresWithSoleReap() async throws { + let spawned = try ProcessLauncher.spawn( + command: "/bin/sh", + arguments: ["-c", "exit 7"], + environment: [:], + workingDirectory: nil + ) + let statusObserver = FailNTimesChildStatusObserver(failures: 3) + let observer = ChildProcessExitObserver( + pid: spawned.pid, + statusObserver: { pid, beforeReap, completion in + statusObserver.observe( + pid: pid, + beforeReap: beforeReap, + completion: completion + ) + } + ) + closeFixtureHandles(spawned) + + let outcome = await observer.wait(timeout: 5) + XCTAssertEqual(outcome, .exited(.exited(code: 7))) + XCTAssertEqual(statusObserver.observationCount, 4) + assertAlreadyReaped(spawned.pid) + } + func testWaitForTerminationStatusReportsRealChildExitAndSignal() async throws { let exiting = try ProcessLauncher.spawn( command: "/bin/sh", @@ -638,6 +673,43 @@ private final class BlockedPreReapGate: @unchecked Sendable { } } +private final class FailNTimesChildStatusObserver: @unchecked Sendable { + private let lock = NSLock() + private let failureCount: Int + private var attempts = 0 + + init(failures: Int) { + failureCount = failures + } + + var observationCount: Int { + lock.lock() + defer { lock.unlock() } + return attempts + } + + func observe( + pid: pid_t, + beforeReap: @escaping @Sendable () -> Void, + completion: @escaping @Sendable (Result) -> Void + ) { + lock.lock() + attempts += 1 + let attempt = attempts + lock.unlock() + + if attempt <= failureCount { + completion(.failure(.waitFailed("injected persistent wait failure \(attempt)"))) + return + } + ProcessTermination.observeChildStatus( + pid: pid, + beforeReap: beforeReap, + completion: completion + ) + } +} + private final class FailOnceChildStatusObserver: @unchecked Sendable { private let lock = NSLock() private var attempts = 0 From a6adc5e2ea224371e391d6242717354bb05b2e38 Mon Sep 17 00:00:00 2001 From: MP Date: Thu, 16 Jul 2026 19:18:11 +0800 Subject: [PATCH 08/21] refactor: consolidate Codex controller teardown core Five teardown routes each maintained their own checklist of tab-scoped task cancellations and controller-runtime field clears; new controller-scoped resources had to be added to every list. The shared mechanical core now lives in cancelCodexTabScopedControllerTasks and clearCodexControllerRuntimeState while semantic decisions (reconnect marking, queue abandonment, interaction settlement, shutdown sequencing, tracking waits) stay caller-owned. --- .../Codex/CodexAgentModeCoordinator.swift | 90 ++++++++----------- 1 file changed, 38 insertions(+), 52 deletions(-) diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift index fe5cf1204..e5c6b49bd 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift @@ -3509,6 +3509,34 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { session.codexControllerFeatureState = nil } + /// Cancels every tab-scoped background task that watches or drives the + /// active Codex controller. Callers own semantic teardown — reconnect + /// marking, queue abandonment, interaction/liveness settlement, shutdown + /// sequencing, and tool-tracking waits. + private func cancelCodexTabScopedControllerTasks(for tabID: UUID) { + cancelCodexIdleShutdown(for: tabID) + cancelCodexTransportClosedFallback(for: tabID) + stopCodexStallWatchdog(for: tabID) + stopBashLivenessTask(for: tabID) + } + + /// Mechanically retires per-controller runtime state on the session — the + /// event stream task, watchdog state, and controller-instance metadata — + /// so every teardown route clears new controller-scoped fields in one place. + private func clearCodexControllerRuntimeState( + for session: AgentModeViewModel.TabSession, + cancelEventTask: Bool = true + ) { + if cancelEventTask { + session.codexEventTask?.cancel() + } + session.codexEventTask = nil + session.codexEventTaskRunID = nil + session.codexLastEventAt = nil + resetCodexWatchdogState(session) + clearCodexControllerInstanceState(for: session) + } + @discardableResult private func invalidateCodexControllerForReconnect( session: AgentModeViewModel.TabSession, @@ -3528,23 +3556,13 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { } else { controllerToShutdown = session.codexController } - cancelCodexTransportClosedFallback(for: session.tabID) markCodexReconnectNeeded(for: session, source: source) - cancelCodexIdleShutdown(for: session.tabID) - stopBashLivenessTask(for: session.tabID) - stopCodexStallWatchdog(for: session.tabID) - if cancelEventTask { - session.codexEventTask?.cancel() - } - session.codexEventTask = nil - session.codexEventTaskRunID = nil - session.codexLastEventAt = nil - resetCodexWatchdogState(session) + cancelCodexTabScopedControllerTasks(for: session.tabID) + clearCodexControllerRuntimeState(for: session, cancelEventTask: cancelEventTask) abandonCodexFallbackQueue( session: session, reason: "Codex queued follow-up was cancelled because the controller was replaced." ) - clearCodexControllerInstanceState(for: session) if !preserveRunID { session.runID = nil } @@ -3694,14 +3712,8 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { controller: any CodexSessionControlling ) async { cancelCodexThreadNameSync(for: session.tabID) - cancelCodexIdleShutdown(for: session.tabID) - cancelCodexTransportClosedFallback(for: session.tabID) - stopBashLivenessTask(for: session.tabID) - stopCodexStallWatchdog(for: session.tabID) - session.codexEventTask?.cancel() - session.codexEventTask = nil - session.codexEventTaskRunID = nil - session.codexLastEventAt = nil + cancelCodexTabScopedControllerTasks(for: session.tabID) + clearCodexControllerRuntimeState(for: session) session.pendingCommandRunningFlushTask?.cancel() session.pendingCommandRunningFlushTask = nil session.pendingCommandRunningByKey.removeAll() @@ -3712,9 +3724,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { resetTrackedCodexTurns(session) session.pendingCodexComputerUseActivation = nil clearCodexPendingInteractions(in: session) - resetCodexWatchdogState(session) clearCodexNativeToolLiveness(session) - clearCodexControllerInstanceState(for: session) await controller.shutdown() await stopCodexToolTrackingAndWait(for: session) } @@ -8135,8 +8145,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { func clearCodexSessionState(_ session: AgentModeViewModel.TabSession) { cancelCodexThreadNameSync(for: session.tabID) - cancelCodexIdleShutdown(for: session.tabID) - stopCodexStallWatchdog(for: session.tabID) + cancelCodexTabScopedControllerTasks(for: session.tabID) clearCodexRecoveryAttempt(for: session.runID) resetCodexResumeTimeoutState(for: session) session.codexConversationID = nil @@ -8165,17 +8174,10 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { session.codexNeedsReconnect = false session.pendingCodexComputerUseActivation = nil clearCodexPendingInteractions(in: session) - cancelCodexTransportClosedFallback(for: session.tabID) - stopBashLivenessTask(for: session.tabID) if let controller = session.codexController { Task { await controller.shutdown() } } - clearCodexControllerInstanceState(for: session) - session.codexEventTask?.cancel() - session.codexEventTask = nil - session.codexEventTaskRunID = nil - session.codexLastEventAt = nil - resetCodexWatchdogState(session) + clearCodexControllerRuntimeState(for: session) clearCodexNativeToolLiveness(session) stopCodexToolTracking(for: session) } @@ -8241,17 +8243,9 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { markCodexReconnectNeeded(for: session, source: "user-cancel-detached", scheduleSave: false) } cancelCodexThreadNameSync(for: session.tabID) - cancelCodexIdleShutdown(for: session.tabID) - cancelCodexTransportClosedFallback(for: session.tabID) - stopCodexStallWatchdog(for: session.tabID) - stopBashLivenessTask(for: session.tabID) - clearCodexControllerInstanceState(for: session) + cancelCodexTabScopedControllerTasks(for: session.tabID) + clearCodexControllerRuntimeState(for: session) session.runID = nil - session.codexEventTask?.cancel() - session.codexEventTask = nil - session.codexEventTaskRunID = nil - session.codexLastEventAt = nil - resetCodexWatchdogState(session) clearCodexNativeToolLiveness(session) settleCodexComputerUseActivationAfterTurn(session, reason: "user-cancel") return { [weak self] in @@ -8305,10 +8299,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { let shutdownRunID = clearTabScopedCoordinatorState ? session.runID : detachedRunID if clearTabScopedCoordinatorState { cancelCodexThreadNameSync(for: session.tabID) - cancelCodexIdleShutdown(for: session.tabID) - cancelCodexTransportClosedFallback(for: session.tabID) - stopCodexStallWatchdog(for: session.tabID) - stopBashLivenessTask(for: session.tabID) + cancelCodexTabScopedControllerTasks(for: session.tabID) } clearCodexRecoveryAttempt(for: session.runID) session.pendingCommandRunningFlushTask?.cancel() @@ -8324,13 +8315,8 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { if let controller = session.codexController { await controller.shutdown() } - clearCodexControllerInstanceState(for: session) + clearCodexControllerRuntimeState(for: session) session.runID = nil - session.codexEventTask?.cancel() - session.codexEventTask = nil - session.codexEventTaskRunID = nil - session.codexLastEventAt = nil - resetCodexWatchdogState(session) if clearTabScopedCoordinatorState { await stopCodexToolTrackingAndWait(for: session) } else { From 6622cfcc5fb5f0cce187f19f79ee14323a222210 Mon Sep 17 00:00:00 2001 From: MP Date: Thu, 16 Jul 2026 19:25:55 +0800 Subject: [PATCH 09/21] refactor: unify Codex termination-task arbitration Both termination origins duplicated the first-claim protocol (same- generation task reuse, terminal-cause claim, task publication) with small drift-prone differences. installTransportTermination now owns the protocol; origins supply only their validation guard, post-claim capture, and completion strategy. --- .../AppServer/CodexAppServerClient.swift | 77 ++++++++++++------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift index bba3c623d..3353217d6 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift @@ -623,24 +623,19 @@ actor CodexAppServerClient { reason: TransportTerminationReason ) -> Task? { if let expectedGeneration, expectedGeneration != transportGeneration { return nil } - if let existing = transportTerminationTask, - existing.generation == transportGeneration - { - return existing.task - } - guard claimTransportTermination(reason: reason) else { return nil } - lastTransportFailure = requestFailure let generation = transportGeneration - let task = Task { [weak self] in - guard let self else { return } - await completeTransportTermination( - generation: generation, - flushStdout: flushStdout, - requestFailure: requestFailure - ) - } - transportTerminationTask = (generation: generation, task: task) - return task + return installTransportTermination( + generation: generation, + reason: reason, + afterClaim: { lastTransportFailure = requestFailure }, + operation: { client in + await client.completeTransportTermination( + generation: generation, + flushStdout: flushStdout, + requestFailure: requestFailure + ) + } + ) } private func beginObservedProcessExitTermination( @@ -655,28 +650,56 @@ actor CodexAppServerClient { else { return nil } + let expectedAgentPIDToClear = ClaimCapture() + return installTransportTermination( + generation: generation, + reason: .observedProcessExit(status: status), + afterClaim: { + expectedAgentPIDToClear.value = takeRegisteredExpectedAgentPIDForDeferredClear() + }, + operation: { client in + await client.completeObservedProcessExitTermination( + status: status, + observer: observer, + generation: generation, + expectedAgentPIDToClear: expectedAgentPIDToClear.value + ) + } + ) + } + + /// Shared first-claim arbitration for every transport-termination origin: + /// same-generation task reuse, the terminal-cause claim, post-claim state + /// capture, and termination-task publication happen identically here so a + /// new termination cause cannot skip or reorder any step of the protocol. + private func installTransportTermination( + generation: UInt64, + reason: TransportTerminationReason, + afterClaim: () -> Void, + operation: @escaping @Sendable (CodexAppServerClient) async -> Void + ) -> Task? { if let existing = transportTerminationTask, existing.generation == generation { return existing.task } - guard claimTransportTermination(reason: .observedProcessExit(status: status)) else { - return nil - } - let expectedAgentPIDToClear = takeRegisteredExpectedAgentPIDForDeferredClear() + guard claimTransportTermination(reason: reason) else { return nil } + afterClaim() let task = Task { [weak self] in guard let self else { return } - await completeObservedProcessExitTermination( - status: status, - observer: observer, - generation: generation, - expectedAgentPIDToClear: expectedAgentPIDToClear - ) + await operation(self) } transportTerminationTask = (generation: generation, task: task) return task } + /// Box for a value produced at claim time (synchronously, before the + /// termination task is published) and consumed inside the escaping + /// termination operation. + private final class ClaimCapture: @unchecked Sendable { + var value: Value? + } + private func claimTransportTermination(reason: TransportTerminationReason) -> Bool { guard !didTerminateTransport else { return false } didTerminateTransport = true From b53e70b6d8cdfbae1b37b743b645a65a90895e24 Mon Sep 17 00:00:00 2001 From: MP Date: Thu, 16 Jul 2026 19:33:53 +0800 Subject: [PATCH 10/21] refactor: keep Codex path pair whole and clarify observer naming The controller now retains CodexRuntimeWorkspacePaths as one property so the launch/execution roles cannot drift through partial initialization. The observer's signaling flag is named for what it guards (rootSignalingClosed) since wait-failure retries reopen it before any reap. Both view-model initializers derive their session projection closures from one factory. --- .../ViewModels/AgentModeViewModel.swift | 53 ++++++++++--------- .../CodexNativeSessionController.swift | 24 ++++----- .../Process/ChildProcessExitObserver.swift | 12 ++--- 3 files changed, 46 insertions(+), 43 deletions(-) diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel.swift index 13bd17dd3..cdd5e7948 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel.swift @@ -1413,18 +1413,8 @@ final class AgentModeViewModel: ObservableObject { let codexWorkspacePathProvider = { [weak workspaceManager] in workspaceManager?.activeWorkspace?.repoPaths.first } - let sessionWorkspacePathProvider: (TabSession) throws -> String? = { session in - try Self.effectiveWorkspacePath( - for: session, - fallbackWorkspacePath: codexWorkspacePathProvider() - ) - } - let codexRuntimeWorkspacePathsProvider: (TabSession) throws -> CodexRuntimeWorkspacePaths = { session in - try Self.codexRuntimeWorkspacePaths( - for: session, - fallbackWorkspacePath: codexWorkspacePathProvider() - ) - } + let (sessionWorkspacePathProvider, codexRuntimeWorkspacePathsProvider) = + Self.makeSessionWorkspaceProviders(fallbackWorkspacePath: codexWorkspacePathProvider) workspacePathProvider = codexWorkspacePathProvider attachmentWorkspaceDirectoryProvider = { [weak workspaceManager] in guard let workspaceManager, workspaceManager.activeWorkspace != nil else { @@ -1647,18 +1637,8 @@ final class AgentModeViewModel: ObservableObject { return FileManager.default.temporaryDirectory } let codexWorkspacePathProvider = { testWorkspacePath } - let sessionWorkspacePathProvider: (TabSession) throws -> String? = { session in - try Self.effectiveWorkspacePath( - for: session, - fallbackWorkspacePath: codexWorkspacePathProvider() - ) - } - let codexRuntimeWorkspacePathsProvider: (TabSession) throws -> CodexRuntimeWorkspacePaths = { session in - try Self.codexRuntimeWorkspacePaths( - for: session, - fallbackWorkspacePath: codexWorkspacePathProvider() - ) - } + let (sessionWorkspacePathProvider, codexRuntimeWorkspacePathsProvider) = + Self.makeSessionWorkspaceProviders(fallbackWorkspacePath: codexWorkspacePathProvider) workspacePathProvider = codexWorkspacePathProvider let codexControllerFactory: CodexAgentModeCoordinator.CodexControllerFactory = codexControllerFactoryWithComputerUse ?? { runID, tabID, windowID, workspacePaths, permissionProfile, taskLabelKind, _ in @@ -1898,6 +1878,31 @@ final class AgentModeViewModel: ObservableObject { ) } + /// One projection contract for both initializers: session-scoped scalar + /// workspace path and Codex launch/execution path pair, derived from the + /// same fallback workspace provider. + private static func makeSessionWorkspaceProviders( + fallbackWorkspacePath: @escaping () -> String? + ) -> ( + sessionWorkspacePath: (TabSession) throws -> String?, + codexRuntimeWorkspacePaths: (TabSession) throws -> CodexRuntimeWorkspacePaths + ) { + ( + sessionWorkspacePath: { session in + try Self.effectiveWorkspacePath( + for: session, + fallbackWorkspacePath: fallbackWorkspacePath() + ) + }, + codexRuntimeWorkspacePaths: { session in + try Self.codexRuntimeWorkspacePaths( + for: session, + fallbackWorkspacePath: fallbackWorkspacePath() + ) + } + ) + } + private static func codexRuntimeWorkspacePaths( for session: TabSession, fallbackWorkspacePath: String? diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift index dd0171ba4..d964a6b11 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift @@ -575,10 +575,9 @@ final class CodexNativeSessionController { private let runID: UUID private let tabID: UUID private let windowID: Int - /// App-server process launch directory (the workspace logical root when worktree-bound). - private let processLaunchDirectory: String? - /// Thread/turn execution directory (the bound worktree when one is selected). - private let executionDirectory: String? + /// Launch/execution directory pair; kept whole so the two roles cannot + /// drift apart through partial initialization or copying. + private let workspacePaths: CodexRuntimeWorkspacePaths private let options: Options private let clientShutdownBehavior: ClientShutdownBehavior private let expectedMCPClientName: String? @@ -756,8 +755,7 @@ final class CodexNativeSessionController { self.runID = runID self.tabID = tabID self.windowID = windowID - processLaunchDirectory = workspacePaths.processLaunchDirectory - executionDirectory = workspacePaths.executionDirectory + self.workspacePaths = workspacePaths self.options = options ?? Self.Options.agentModeDefault(forceExperimentalSteering: forceExperimentalSteering) self.clientShutdownBehavior = clientShutdownBehavior self.expectedMCPClientName = expectedMCPClientName @@ -948,7 +946,7 @@ final class CodexNativeSessionController { return } guard let fileURL = Self.makeRawEventLogFileURL( - executionDirectory: executionDirectory, + executionDirectory: workspacePaths.executionDirectory, threadID: threadIdentifier ) else { return @@ -1002,7 +1000,7 @@ final class CodexNativeSessionController { "runID": runID.uuidString, "tabID": tabID.uuidString, "threadID": threadID ?? "", - "workspacePath": executionDirectory ?? "" + "workspacePath": workspacePaths.executionDirectory ?? "" ]) } var record: [String: Any] = [ @@ -1084,7 +1082,7 @@ final class CodexNativeSessionController { .init(clientName: expectedMCPClientName, runID: runID) ) } - await client.updateProcessLaunchDirectory(processLaunchDirectory) + await client.updateProcessLaunchDirectory(workspacePaths.processLaunchDirectory) await updateClientProcessLaunchPolicy() // Re-check: the pre-launch setup above has suspension points after the first check. try Task.checkCancellation() @@ -1107,7 +1105,7 @@ final class CodexNativeSessionController { params["effort"] = reasoningEffort } Self.addServiceTier(serviceTier, to: ¶ms) - if let executionDirectory { + if let executionDirectory = workspacePaths.executionDirectory { params["cwd"] = executionDirectory } if !configOverrides.isEmpty { @@ -1136,7 +1134,7 @@ final class CodexNativeSessionController { params["effort"] = reasoningEffort } Self.addServiceTier(serviceTier, to: ¶ms) - if let executionDirectory { + if let executionDirectory = workspacePaths.executionDirectory { params["cwd"] = executionDirectory } if !configOverrides.isEmpty { @@ -1394,7 +1392,7 @@ final class CodexNativeSessionController { params["effort"] = reasoningEffort } Self.addServiceTier(serviceTier, to: ¶ms) - if let executionDirectory { + if let executionDirectory = workspacePaths.executionDirectory { params["cwd"] = executionDirectory } #if DEBUG @@ -1411,7 +1409,7 @@ final class CodexNativeSessionController { requestParams["approvalsReviewer"] = options.approvalReviewerProvider().appServerRequestValue requestParams["sandboxPolicy"] = Self.appServerTurnSandboxPolicyPayload( mode: sandboxMode, - executionDirectory: executionDirectory + executionDirectory: workspacePaths.executionDirectory ) // app-server v2 turn/start does not accept a config override bag. // Thread-level config changes take effect on thread/start or thread/resume. diff --git a/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift b/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift index c07d0d6a3..c3c0f7e27 100644 --- a/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift +++ b/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift @@ -19,13 +19,13 @@ final class ChildProcessExitObserver: @unchecked Sendable { private final class State: @unchecked Sendable { private let lock = NSLock() - private var rootReaped = false + private var rootSignalingClosed = false private var outcome: Outcome? private var waiters: [UUID: CheckedContinuation] = [:] func closeRootSignalingBeforeReap() { lock.lock() - rootReaped = true + rootSignalingClosed = true lock.unlock() } @@ -33,7 +33,7 @@ final class ChildProcessExitObserver: @unchecked Sendable { lock.lock() defer { lock.unlock() } guard outcome == nil else { return false } - rootReaped = false + rootSignalingClosed = false return true } @@ -46,7 +46,7 @@ final class ChildProcessExitObserver: @unchecked Sendable { self.outcome = outcome // Keep publication idempotently closed to PID signaling even if a // future result path reaches finish without the callback boundary. - rootReaped = true + rootSignalingClosed = true let continuations = waiters.values waiters.removeAll() lock.unlock() @@ -82,14 +82,14 @@ final class ChildProcessExitObserver: @unchecked Sendable { func withRootSignalingWindow(_ operation: () -> T) -> T? { lock.lock() defer { lock.unlock() } - guard !rootReaped, outcome == nil else { return nil } + guard !rootSignalingClosed, outcome == nil else { return nil } return operation() } var isRootSignalingClosed: Bool { lock.lock() defer { lock.unlock() } - return rootReaped + return rootSignalingClosed } private func expireWaiter(_ waiterID: UUID) { From 00f8c2577a3adc56e31e8224bfcf8aca3037fe5c Mon Sep 17 00:00:00 2001 From: MP Date: Thu, 16 Jul 2026 19:38:29 +0800 Subject: [PATCH 11/21] refactor: hand claim context to termination task immutably The post-claim capture now flows through a generic Sendable return value instead of a mutable unchecked box, so the compiler enforces the capture-before-publication handoff. --- .../AppServer/CodexAppServerClient.swift | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift index 3353217d6..a4a6468e2 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift @@ -627,8 +627,8 @@ actor CodexAppServerClient { return installTransportTermination( generation: generation, reason: reason, - afterClaim: { lastTransportFailure = requestFailure }, - operation: { client in + captureAfterClaim: { lastTransportFailure = requestFailure }, + operation: { client, _ in await client.completeTransportTermination( generation: generation, flushStdout: flushStdout, @@ -650,19 +650,16 @@ actor CodexAppServerClient { else { return nil } - let expectedAgentPIDToClear = ClaimCapture() return installTransportTermination( generation: generation, reason: .observedProcessExit(status: status), - afterClaim: { - expectedAgentPIDToClear.value = takeRegisteredExpectedAgentPIDForDeferredClear() - }, - operation: { client in + captureAfterClaim: { takeRegisteredExpectedAgentPIDForDeferredClear() }, + operation: { client, expectedAgentPIDToClear in await client.completeObservedProcessExitTermination( status: status, observer: observer, generation: generation, - expectedAgentPIDToClear: expectedAgentPIDToClear.value + expectedAgentPIDToClear: expectedAgentPIDToClear ) } ) @@ -672,11 +669,11 @@ actor CodexAppServerClient { /// same-generation task reuse, the terminal-cause claim, post-claim state /// capture, and termination-task publication happen identically here so a /// new termination cause cannot skip or reorder any step of the protocol. - private func installTransportTermination( + private func installTransportTermination( generation: UInt64, reason: TransportTerminationReason, - afterClaim: () -> Void, - operation: @escaping @Sendable (CodexAppServerClient) async -> Void + captureAfterClaim: () -> ClaimContext, + operation: @escaping @Sendable (CodexAppServerClient, ClaimContext) async -> Void ) -> Task? { if let existing = transportTerminationTask, existing.generation == generation @@ -684,22 +681,15 @@ actor CodexAppServerClient { return existing.task } guard claimTransportTermination(reason: reason) else { return nil } - afterClaim() + let claimContext = captureAfterClaim() let task = Task { [weak self] in guard let self else { return } - await operation(self) + await operation(self, claimContext) } transportTerminationTask = (generation: generation, task: task) return task } - /// Box for a value produced at claim time (synchronously, before the - /// termination task is published) and consumed inside the escaping - /// termination operation. - private final class ClaimCapture: @unchecked Sendable { - var value: Value? - } - private func claimTransportTermination(reason: TransportTerminationReason) -> Bool { guard !didTerminateTransport else { return false } didTerminateTransport = true From 8d8637748fa3f8789606765c1208797f02931ced Mon Sep 17 00:00:00 2001 From: MP Date: Thu, 16 Jul 2026 20:01:09 +0800 Subject: [PATCH 12/21] fix: self-heal missed child exit notifications kevent NOTE_EXIT delivery proved non-contractual under saturation (a 64-live/80-early-exit harness stranded one child in roughly a quarter of runs). Every registration now keeps a 500 ms non-destructive fallback probe that routes a missed notification through the same token-guarded sole-reap path; the timer is cancelled at completion. --- .../Process/ProcessTermination.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Sources/RepoPrompt/Infrastructure/Process/ProcessTermination.swift b/Sources/RepoPrompt/Infrastructure/Process/ProcessTermination.swift index 0c0f4dce1..ca035c868 100644 --- a/Sources/RepoPrompt/Infrastructure/Process/ProcessTermination.swift +++ b/Sources/RepoPrompt/Infrastructure/Process/ProcessTermination.swift @@ -77,22 +77,33 @@ private final class ChildStatusReaperRegistry: @unchecked Sendable { private final class Entry { let token: UUID let source: any DispatchSourceProcess + let fallbackProbeTimer: any DispatchSourceTimer let beforeReap: @Sendable () -> Void let completion: @Sendable (Result) -> Void init( token: UUID, source: any DispatchSourceProcess, + fallbackProbeTimer: any DispatchSourceTimer, beforeReap: @escaping @Sendable () -> Void, completion: @escaping @Sendable (Result) -> Void ) { self.token = token self.source = source + self.fallbackProbeTimer = fallbackProbeTimer self.beforeReap = beforeReap self.completion = completion } } + /// kevent exit delivery is not contractual under load: a process source + /// can rarely miss NOTE_EXIT even though it registered while the child was + /// alive, which would strand the observer and the child's zombie forever. + /// Each registration therefore keeps a low-frequency non-destructive probe + /// that self-heals a missed notification through the same token-guarded + /// sole-reap path. + private static let fallbackProbeInterval: TimeInterval = 0.5 + private let queue = DispatchQueue( label: "com.repoprompt.process-termination.child-status-registry", qos: .userInitiated @@ -117,9 +128,11 @@ private final class ChildStatusReaperRegistry: @unchecked Sendable { eventMask: .exit, queue: queue ) + let fallbackProbeTimer = DispatchSource.makeTimerSource(queue: queue) let entry = Entry( token: token, source: source, + fallbackProbeTimer: fallbackProbeTimer, beforeReap: beforeReap, completion: completion ) @@ -133,6 +146,16 @@ private final class ChildStatusReaperRegistry: @unchecked Sendable { } source.activate() + fallbackProbeTimer.schedule( + deadline: .now() + Self.fallbackProbeInterval, + repeating: Self.fallbackProbeInterval, + leeway: .milliseconds(100) + ) + fallbackProbeTimer.setEventHandler { [weak self] in + self?.probeWithoutBlocking(pid: pid, token: token) + } + fallbackProbeTimer.activate() + // Activation and registration are distinct libdispatch steps. This // probe plus the registration-handler probe cover exits on either // side of that boundary without consuming a live child's status. @@ -224,6 +247,7 @@ private final class ChildStatusReaperRegistry: @unchecked Sendable { guard entries[pid]?.token == token else { return } entries.removeValue(forKey: pid) entry.source.cancel() + entry.fallbackProbeTimer.cancel() } } From f65e925dffcbaf9abd4ac97520c1006e5871d7e8 Mon Sep 17 00:00:00 2001 From: MP Date: Fri, 17 Jul 2026 10:03:07 +0800 Subject: [PATCH 13/21] test: align Codex readiness coverage with path pairs --- Scripts/Fixtures/test-suite-contract-ledger.tsv | 1 + .../AgentMode/Codex/CodexMCPBootstrapReadinessTests.swift | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index 016f51e17..32bde428f 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -1143,6 +1143,7 @@ root/RepoPromptTests.CodexMCPBootstrapReadinessTests/testThrowingProvisionerAbor root/RepoPromptTests.CodexMCPRoutingReadinessTests/testCancellationDuringRoutingWaitDoesNotReachFirstTurn root Tests/RepoPromptTests/AgentMode/Codex/CodexMCPRoutingReadinessTests.swift RepoPromptTests.CodexMCPRoutingReadinessTests testCancellationDuringRoutingWaitDoesNotReachFirstTurn AgentMode codex.agent_mode.fail_closed_mcp_routing_boundary routing_gate,cancellation,pre_first_turn cancellation_contract root_swiftpm routine 1 RoutingReadinessFakeCodexController,TerminalPublicationRecorder,RunIDBox A run cancelled while the MCP routing wait is suspended returns a cancelled outcome, never calls startUserTurn, and publishes no terminal state. A cancellation racing the suspended routing wait could let the child dispatch its first turn or publish a spurious fail-closed terminal state. 0.030000 global_connection_gate;server_network_manager;async_tasks mcp_shared_server;ServerNetworkManager MCPSharedServerTestLease retain 0 Issue #514: a run cancelled during the routing wait does not reach its first turn. root/RepoPromptTests.CodexMCPRoutingReadinessTests/testResumingChildWhoseRoutingConfirmsReachesFirstTurn root Tests/RepoPromptTests/AgentMode/Codex/CodexMCPRoutingReadinessTests.swift RepoPromptTests.CodexMCPRoutingReadinessTests testResumingChildWhoseRoutingConfirmsReachesFirstTurn AgentMode codex.agent_mode.fail_closed_mcp_routing_boundary routing_gate,resume,pre_first_turn async_state_machine root_swiftpm routine 1 RoutingReadinessFakeCodexController,TerminalPublicationRecorder A reconnecting Codex child whose routing waiter is signalled during connection-policy install dispatches its first turn exactly once and publishes no terminal state, so a healthy routed resume is not failed closed. The pre-first-turn routing gate could spuriously fail a resumed Codex child whose connection did route, blocking a healthy reconnect. 0.012000 global_connection_gate;server_network_manager;async_tasks mcp_shared_server;ServerNetworkManager MCPSharedServerTestLease retain 0 Issue #514: a routed Codex resume reaches its first turn instead of failing closed. root/RepoPromptTests.CodexMCPRoutingReadinessTests/testUnroutedChildFailsClosedBeforeFirstTurnWithoutLeakingBootstrapState root Tests/RepoPromptTests/AgentMode/Codex/CodexMCPRoutingReadinessTests.swift RepoPromptTests.CodexMCPRoutingReadinessTests testUnroutedChildFailsClosedBeforeFirstTurnWithoutLeakingBootstrapState AgentMode codex.agent_mode.fail_closed_mcp_routing_boundary routing_gate,pre_first_turn,bootstrap_cleanup async_state_machine root_swiftpm routine 1 RoutingReadinessFakeCodexController,TerminalPublicationRecorder When a Codex child's thread starts but its routing wait times out, startUserTurn never fires, the send outcome and exactly one terminal publication both report a failed run, exactly one readiness error is recorded in the child transcript, the controller is released, and the bootstrap gate, routing waiter, and one-shot connection policy are all cleared. A tool-less Codex child could reach its first turn over an unrouted connection, or a failed start could leak the bootstrap gate, routing waiter, or pending connection policy. 0.538000 global_connection_gate;server_network_manager;async_tasks mcp_shared_server;ServerNetworkManager MCPSharedServerTestLease retain 0 Issue #514: an unrouted Codex child fails closed before its first turn with no leaked bootstrap state. +root/RepoPromptTests.CodexMCPRoutingReadinessTests/testUnroutedResumeFailsClosedWithResumeFailurePrefix root Tests/RepoPromptTests/AgentMode/Codex/CodexMCPRoutingReadinessTests.swift RepoPromptTests.CodexMCPRoutingReadinessTests testUnroutedResumeFailsClosedWithResumeFailurePrefix AgentMode codex.agent_mode.fail_closed_mcp_routing_boundary routing_gate,resume,pre_first_turn,error_classification async_state_machine root_swiftpm routine 1 RoutingReadinessFakeCodexController,TerminalPublicationRecorder An unrouted resumed Codex child never dispatches its next turn, publishes exactly one failed terminal state, records exactly one routing-readiness error with the native resume failure prefix, and records no fresh-start failure prefix. An unrouted resume could dispatch a turn, publish duplicate terminal state, or be mislabeled as a fresh start and obscure the failing lifecycle boundary. 0.538000 global_connection_gate;server_network_manager;async_tasks mcp_shared_server;ServerNetworkManager MCPSharedServerTestLease retain 0 Issue #514: an unrouted Codex resume fails closed with resume-specific classification. root/RepoPromptTests.CodexModelPollingServiceTests/testLastSubscriberStopsOwnedClientAndLaterSubscriberRestartsPolling root Tests/RepoPromptTests/AI/CodexModelPollingServiceTests.swift RepoPromptTests.CodexModelPollingServiceTests testLastSubscriberStopsOwnedClientAndLaterSubscriberRestartsPolling AI unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.032000 unreviewed retain_pending_review 0 initial census source line 5 root/RepoPromptTests.CodexNativeSessionControllerEventRecoveryTests/testActualCodexWebSearchLifecycleShapesPreserveQueriesAndIgnoreRawDuplicate root Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerEventRecoveryTests.swift RepoPromptTests.CodexNativeSessionControllerEventRecoveryTests testActualCodexWebSearchLifecycleShapesPreserveQueriesAndIgnoreRawDuplicate AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.001000 unreviewed retain_pending_review 0 initial census source line 240 root/RepoPromptTests.CodexNativeSessionControllerEventRecoveryTests/testAssistantCompleteReconciliationIsIsolatedAndResetsWhenTurnCompletes root Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerEventRecoveryTests.swift RepoPromptTests.CodexNativeSessionControllerEventRecoveryTests testAssistantCompleteReconciliationIsIsolatedAndResetsWhenTurnCompletes AgentMode codex.transcript.assistant_reconciliation assistant,item_completed,terminal_barrier lifecycle_regression root_swiftpm routine 1 Streamed and authoritative assistant content yields the asserted rows, ordering, tail, and terminal drain state. Assistant output could duplicate, lose UTF-8 tails, cross tool boundaries, or regress PR #299 terminal conclusions. 0.001000 test_case retain 0 Codex app-server modernization Items 1–3 integration coverage. diff --git a/Tests/RepoPromptTests/AgentMode/Codex/CodexMCPBootstrapReadinessTests.swift b/Tests/RepoPromptTests/AgentMode/Codex/CodexMCPBootstrapReadinessTests.swift index ef6a2ddf2..d72f9ea42 100644 --- a/Tests/RepoPromptTests/AgentMode/Codex/CodexMCPBootstrapReadinessTests.swift +++ b/Tests/RepoPromptTests/AgentMode/Codex/CodexMCPBootstrapReadinessTests.swift @@ -38,7 +38,7 @@ final class CodexMCPBootstrapReadinessTests: XCTestCase { runID: UUID(), tabID: UUID(), windowID: 0, - workspacePath: "/tmp/codex-mcp-readiness-throwing", + workspacePaths: .uniform("/tmp/codex-mcp-readiness-throwing"), options: options, clientShutdownBehavior: .stopOnShutdown, expectedMCPClientName: expectedClientName, @@ -81,7 +81,7 @@ final class CodexMCPBootstrapReadinessTests: XCTestCase { runID: UUID(), tabID: UUID(), windowID: 0, - workspacePath: "/tmp/codex-mcp-readiness-cancellation", + workspacePaths: .uniform("/tmp/codex-mcp-readiness-cancellation"), options: options, clientShutdownBehavior: .stopOnShutdown, expectedMCPClientName: expectedClientName, @@ -129,7 +129,7 @@ final class CodexMCPBootstrapReadinessTests: XCTestCase { commandName: executableURL.path, additionalPathHints: [], requestTimeout: 5, - workingDirectory: directory.path + processLaunchDirectory: directory.path ) ) @@ -147,7 +147,7 @@ final class CodexMCPBootstrapReadinessTests: XCTestCase { runID: UUID(), tabID: UUID(), windowID: 0, - workspacePath: directory.path, + workspacePaths: .uniform(directory.path), options: options, clientShutdownBehavior: .stopOnShutdown, expectedMCPClientName: expectedClientName, From 3bfaec2b9e596c3cff4e61f39bc0d167c976997d Mon Sep 17 00:00:00 2001 From: MP Date: Fri, 17 Jul 2026 11:16:07 +0800 Subject: [PATCH 14/21] fix: stabilize SwiftFormat policy across versions --- .swiftformat | 3 +++ Scripts/install_format_tools.sh | 40 +++++++++++++++++++++++++++++--- Scripts/test_release_tooling.py | 41 +++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/.swiftformat b/.swiftformat index 5168ad475..968ddad41 100644 --- a/.swiftformat +++ b/.swiftformat @@ -16,4 +16,7 @@ --wrapcollections before-first --maxwidth none +# Keep the active rule policy explicit so formatter upgrades cannot silently add rules. +--rules andOperator,anyObjectProtocol,applicationMain,assertionFailures,blankLineAfterImports,blankLinesAroundMark,blankLinesAtEndOfScope,blankLinesAtStartOfScope,blankLinesBetweenChainedFunctions,blankLinesBetweenImports,blankLinesBetweenScopes,braces,conditionalAssignment,consecutiveBlankLines,consecutiveSpaces,consistentSwitchCaseSpacing,docComments,docCommentsBeforeModifiers,duplicateImports,elseOnSameLine,emptyBraces,emptyExtensions,enumNamespaces,environmentEntry,extensionAccessControl,fileHeader,fileMacro,genericExtensions,headerFileName,hoistAwait,hoistPatternLet,hoistTry,indent,initCoderUnavailable,leadingDelimiters,linebreakAtEndOfFile,linebreaks,modifierOrder,modifiersOnSameLine,noForceTryInTests,noForceUnwrapInTests,numberFormatting,opaqueGenericParameters,preferCountWhere,preferForLoop,preferKeyPath,redundantAsync,redundantBackticks,redundantBreak,redundantClosure,redundantEmptyView,redundantEquatable,redundantExtensionACL,redundantFileprivate,redundantGet,redundantInit,redundantInternal,redundantLet,redundantLetError,redundantMemberwiseInit,redundantNilInit,redundantObjc,redundantOptionalBinding,redundantParens,redundantPattern,redundantPublic,redundantRawValues,redundantReturn,redundantSelf,redundantSendable,redundantStaticSelf,redundantSwiftTestingSuite,redundantThrows,redundantType,redundantTypedThrows,redundantVariable,redundantViewBuilder,redundantVoidReturnType,semicolons,simplifyGenericConstraints,sortDeclarations,sortImports,sortTypealiases,spaceAroundBraces,spaceAroundBrackets,spaceAroundComments,spaceAroundGenerics,spaceAroundOperators,spaceAroundParens,spaceInsideBraces,spaceInsideBrackets,spaceInsideComments,spaceInsideGenerics,spaceInsideParens,strongOutlets,strongifiedSelf,swiftTestingTestCaseNames,todos,trailingClosures,trailingCommas,trailingSpace,typeSugar,unusedArguments,void,wrap,wrapArguments,wrapAttributes,wrapFunctionBodies,wrapLoopBodies,wrapMultilineStatementBraces,wrapPropertyBodies,wrapSingleLineComments,yodaConditions + --exclude .build,.swiftpm,DerivedData,build,Vendor,Packages/RepoPromptAgentProviders/.build,Sources/CSwiftPCRE2,Sources/RepoPromptC,Sources/RepoPrompt/ThirdParty/SwiftPCRE2,Sources/RepoPrompt/Infrastructure/AI/Prompts/Workflows/WorkflowPromptSharedFragments.swift,Sources/RepoPrompt/Infrastructure/AI/Prompts/Workflows/WorkflowPrompt+Build.swift,Sources/RepoPrompt/Infrastructure/AI/Prompts/Workflows/WorkflowPrompt+DeepPlan.swift,Sources/RepoPrompt/Infrastructure/AI/Prompts/Workflows/WorkflowPrompt+Investigate.swift,Sources/RepoPrompt/Infrastructure/AI/Prompts/Workflows/WorkflowPrompt+Optimize.swift,Sources/RepoPrompt/Infrastructure/AI/Prompts/Workflows/WorkflowPrompt+OracleExport.swift,Sources/RepoPrompt/Infrastructure/AI/Prompts/Workflows/WorkflowPrompt+Orchestrate.swift,Sources/RepoPrompt/Infrastructure/AI/Prompts/Workflows/WorkflowPrompt+Refactor.swift,Sources/RepoPrompt/Infrastructure/AI/Prompts/Workflows/WorkflowPrompt+Reminder.swift,Sources/RepoPrompt/Infrastructure/AI/Prompts/Workflows/WorkflowPrompt+Review.swift diff --git a/Scripts/install_format_tools.sh b/Scripts/install_format_tools.sh index ec8bf4086..737b4506d 100755 --- a/Scripts/install_format_tools.sh +++ b/Scripts/install_format_tools.sh @@ -3,6 +3,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ACTION="status" +SWIFTFORMAT_MIN_VERSION="0.61.1" if (( $# > 0 )) && [[ "${1:-}" != -* ]]; then ACTION="$1" @@ -44,6 +45,33 @@ swiftlint_version(){ swiftlint version 2>/dev/null || swiftlint --version 2>/dev/null || true } +version_at_least(){ + local actual="${1%%-*}" + local minimum="$2" + local actual_major actual_minor actual_patch + local minimum_major minimum_minor minimum_patch + + IFS=. read -r actual_major actual_minor actual_patch <<<"$actual" + IFS=. read -r minimum_major minimum_minor minimum_patch <<<"$minimum" + for component in "$actual_major" "$actual_minor" "$actual_patch" "$minimum_major" "$minimum_minor" "$minimum_patch"; do + [[ "$component" =~ ^[0-9]+$ ]] || return 1 + done + + if (( actual_major != minimum_major )); then + (( actual_major > minimum_major )) + return + fi + if (( actual_minor != minimum_minor )); then + (( actual_minor > minimum_minor )) + return + fi + (( actual_patch >= minimum_patch )) +} + +swiftformat_is_supported(){ + has_tool swiftformat && version_at_least "$(swiftformat_version)" "$SWIFTFORMAT_MIN_VERSION" +} + print_tool_status(){ local name="$1" local command_name="$2" @@ -55,7 +83,9 @@ print_tool_status(){ swiftlint) version="$(swiftlint_version)" ;; *) version="" ;; esac - if [[ -n "$version" ]]; then + if [[ "$command_name" == "swiftformat" ]] && ! version_at_least "$version" "$SWIFTFORMAT_MIN_VERSION"; then + echo " $name: unsupported ($version; requires >= $SWIFTFORMAT_MIN_VERSION)" + elif [[ -n "$version" ]]; then echo " $name: OK ($version)" else echo " $name: OK ($(command -v "$command_name"))" @@ -72,7 +102,7 @@ print_status(){ } all_tools_present(){ - has_tool swiftformat && has_tool swiftlint + swiftformat_is_supported && has_tool swiftlint } print_remediation(){ @@ -81,6 +111,7 @@ Install missing format tools with: make install-format-tools Or directly with Homebrew: + brew upgrade swiftformat brew install swiftformat swiftlint EOF } @@ -99,8 +130,11 @@ install_missing_tools(){ fail "Homebrew is required to install SwiftFormat and SwiftLint. Install Homebrew first, then rerun 'make install-format-tools'." fi - if has_tool swiftformat; then + if swiftformat_is_supported; then echo "SwiftFormat already installed." + elif has_tool swiftformat; then + echo "Upgrading SwiftFormat with Homebrew..." + brew upgrade swiftformat || brew install swiftformat else echo "Installing SwiftFormat with Homebrew..." brew install swiftformat diff --git a/Scripts/test_release_tooling.py b/Scripts/test_release_tooling.py index 630838992..ae9c53aad 100644 --- a/Scripts/test_release_tooling.py +++ b/Scripts/test_release_tooling.py @@ -1815,6 +1815,47 @@ def test_swift_style_lint_uses_config_discovery_without_script_input_overhead(se ): self.assertIn(f" - {excluded_path}", swiftlint_config) + def test_swiftformat_rule_policy_is_explicit_and_stable(self) -> None: + swiftformat_config = (SCRIPT_DIR.parent / ".swiftformat").read_text(encoding="utf-8") + explicit_rule_lines = [ + line for line in swiftformat_config.splitlines() if line.startswith("--rules ") + ] + + self.assertEqual(len(explicit_rule_lines), 1) + explicit_rules = explicit_rule_lines[0].removeprefix("--rules ").split(",") + self.assertEqual(explicit_rules, sorted(set(explicit_rules))) + self.assertEqual(len(explicit_rules), 113) + policy_digest = hashlib.sha256(",".join(explicit_rules).encode("utf-8")).hexdigest() + self.assertEqual(policy_digest, "da05f3f15cb054b0fd2421004a58876a7839ba45ff3c753aa91054b20c242ee6") + + def test_format_tool_check_rejects_unsupported_swiftformat_versions(self) -> None: + installer = SCRIPT_DIR / "install_format_tools.sh" + with tempfile.TemporaryDirectory() as temp_dir: + bin_dir = Path(temp_dir) + swiftlint = bin_dir / "swiftlint" + swiftlint.write_text("#!/bin/sh\necho 0.58.0\n", encoding="utf-8") + swiftlint.chmod(0o755) + + for version, expected_success in (("0.60.10", False), ("0.61.1", True), ("0.62.1", True)): + with self.subTest(version=version): + swiftformat = bin_dir / "swiftformat" + swiftformat.write_text(f"#!/bin/sh\necho {version}\n", encoding="utf-8") + swiftformat.chmod(0o755) + environment = dict(os.environ) + environment["PATH"] = f"{bin_dir}:/usr/bin:/bin" + result = subprocess.run( + [str(installer), "check"], + text=True, + capture_output=True, + env=environment, + check=False, + ) + + self.assertEqual(result.returncode == 0, expected_success, result.stdout + result.stderr) + if not expected_success: + self.assertIn("unsupported", result.stdout) + self.assertIn("requires >= 0.61.1", result.stdout) + def test_publish_staged_validates_before_creating_dist(self) -> None: release_script = (SCRIPT_DIR / "release.sh").read_text(encoding="utf-8") publish_staged = release_script.split("publish_staged_release() {", 1)[1].split("\n}", 1)[0] From 1654a26be8a9c55e1317720c23e1a2774785355f Mon Sep 17 00:00:00 2001 From: MP Date: Fri, 17 Jul 2026 11:49:03 +0800 Subject: [PATCH 15/21] fix: preserve replacement controller during stale teardown --- .../Fixtures/test-suite-contract-ledger.tsv | 2 +- .../Codex/CodexAgentModeCoordinator.swift | 44 +++++++--- .../MCP/AgentRunWorktreeStartTests.swift | 85 ++++++++++++++++++- 3 files changed, 118 insertions(+), 13 deletions(-) diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index 32bde428f..d14518068 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -389,7 +389,7 @@ root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionMaterialize root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionValidatesAndMaterializesChangedSecondaryBindingWithoutRestartingPrimaryIdentity root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testBindingTransitionValidatesAndMaterializesChangedSecondaryBindingWithoutRestartingPrimaryIdentity MCP agent.worktree.binding-transition.secondary behavioral_regression root_swiftpm routine 2 AgentRunWorktreeStartLifecycleFixture Unavailable secondary binding rejects atomically; valid changed secondary materializes both physical roots without restarting provider or Codex identity high 0.804500 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture consolidated_replacement 0 replaces root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionMaterializesChangedSecondaryBindingWithoutRestartingUnchangedPrimaryIdentity; root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionRejectsUnavailableSecondaryBindingEvenWhenPrimaryIdentityIsUnchanged root/RepoPromptTests.AgentRunWorktreeStartTests/testCanonicalAgentRunReviewSourceStagesBindsToFreshWorktreeChildAndCleansUp root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCanonicalAgentRunReviewSourceStagesBindsToFreshWorktreeChildAndCleansUp MCP agent.oracle_review.canonical_run_binding launch_snapshot,exact_run,cleanup deterministic_lifecycle_integration root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture Canonical source packaging stages for an exact fresh worktree child run with an independent target binding and disappears on control deactivation. A stale or sibling child run could consume source-owned review packaging. 0.107000 suite_owned_temp_roots;awaited_window_teardown WindowStatesManager AgentRunWorktreeStartLifecycleFixture retain 0 Issue #264 exact-ID ledger reconciliation root/RepoPromptTests.AgentRunWorktreeStartTests/testChildSessionWorktreeBindingInheritanceCanBeOptedOut root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testChildSessionWorktreeBindingInheritanceCanBeOptedOut MCP unreviewed unreviewed root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture unreviewed unreviewed 0.003000 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain_pending_review 0 initial census source line 76 -root/RepoPromptTests.AgentRunWorktreeStartTests/testCodexControllerFailsClosedWhenInstalledLaunchRootBecomesMalformed root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCodexControllerFailsClosedWhenInstalledLaunchRootBecomesMalformed MCP agent.worktree.codex-controller-resolution-failure-teardown codex_launch_execution_split,approval,follow_up,computer_use,liveness,tool_tracking lifecycle_regression root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture;ReplacementIdentityFakeCodexController An installed controller whose selected logical root becomes malformed is shut down and loses its identity, event task, approval, queued follow-up, computer-use activation, native-tool liveness, and tool tracking while preserving the failed run identity. A workspace resolution failure could leave controller-scoped tasks or interactions active after the controller becomes unusable. 0.020000 suite_owned_temp_roots;suite_owned_immutable_git_seed GlobalSettingsStore.globalCodeMapsDisabled;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 issue #554 WI-1 fail-closed installed-controller teardown +root/RepoPromptTests.AgentRunWorktreeStartTests/testCodexControllerFailsClosedWhenInstalledLaunchRootBecomesMalformed root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCodexControllerFailsClosedWhenInstalledLaunchRootBecomesMalformed MCP agent.worktree.codex-controller-resolution-failure-teardown codex_launch_execution_split,approval,follow_up,computer_use,liveness,tool_tracking,replacement_identity lifecycle_regression root_swiftpm routine 3 AgentRunWorktreeStartLifecycleFixture;ReplacementIdentityFakeCodexController An installed controller whose selected logical root becomes malformed is shut down and loses its correlated runtime state, while a stale failure that settles after a replacement is installed shuts down only the captured controller and preserves the replacement's identity, event task, interaction state, and tool tracking; matching teardown removes old tracking before awaiting shutdown so a successor tracker installed during that suspension survives. A workspace resolution failure could leave unusable controller state active, clear a newer controller that was installed while terminal failure publication suspended, or remove successor tracking after an awaited stale-controller shutdown. 0.020000 suite_owned_temp_roots;suite_owned_immutable_git_seed GlobalSettingsStore.globalCodeMapsDisabled;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 issue #554 WI-1 fail-closed installed-controller teardown and stale-publication replacement guard and shutdown-await tracking ordering root/RepoPromptTests.AgentRunWorktreeStartTests/testCodexControllerReplacementKeysOnExecutionChangeAndSurvivesSecondaryBindingChanges root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCodexControllerReplacementKeysOnExecutionChangeAndSurvivesSecondaryBindingChanges MCP agent.worktree.codex-controller-replacement-key codex_launch_execution_split lifecycle_regression root_swiftpm routine 4 AgentRunWorktreeStartLifecycleFixture;ReplacementIdentityFakeCodexController Factory invocation pairs and controller ObjectIdentifier across ensure passes for creation, unchanged pair, secondary-binding change, and execution-directory change Codex controller could be recycled on secondary-binding churn or kept alive across an execution-directory change 0.088000 suite_owned_temp_roots;suite_owned_immutable_git_seed GlobalSettingsStore.globalCodeMapsDisabled;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 issue #554 WI-1 Codex launch/execution path pair coverage root/RepoPromptTests.AgentRunWorktreeStartTests/testCodexControllerReplacementKeysOnLaunchOnlyChange root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCodexControllerReplacementKeysOnLaunchOnlyChange MCP agent.worktree.codex-controller-replacement-launch-only codex_launch_execution_split lifecycle_regression root_swiftpm routine 2 AgentRunWorktreeStartLifecycleFixture;ReplacementIdentityFakeCodexController Factory invocation pairs and controller ObjectIdentifier across a logical-root-only binding change with unchanged execution directory A launch-directory-only change could reuse a Codex controller whose app-server was launched from the wrong root 0.038000 suite_owned_temp_roots;suite_owned_immutable_git_seed GlobalSettingsStore.globalCodeMapsDisabled;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 issue #554 WI-1 Codex launch/execution path pair coverage root/RepoPromptTests.AgentRunWorktreeStartTests/testCodexRuntimeWorkspacePathsFailsClosedOnMalformedSelectedLogicalRoot root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testCodexRuntimeWorkspacePathsFailsClosedOnMalformedSelectedLogicalRoot MCP agent.worktree.codex-launch-root-validation codex_launch_execution_split protocol_negative root_swiftpm routine 3 AgentRunWorktreeStartLifecycleFixture Typed emptyLogicalRoot and launchDirectoryUnavailable errors plus the nil/nil fallback pair A malformed selected logical root could reach process spawn as an opaque launch failure instead of a pre-startup typed error 0.001000 suite_owned_temp_roots;suite_owned_immutable_git_seed AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 issue #554 WI-1 Codex launch/execution path pair coverage diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift index e5c6b49bd..1f011e716 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift @@ -212,6 +212,9 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { private weak var viewModel: AgentModeViewModel? private var terminalCommitBarrier: AgentRunTerminalCommitBarrier? + #if DEBUG + private var testWorkspaceResolutionFailurePublicationGate: (@Sendable () async -> Void)? + #endif private var toolTrackingByTabID: [UUID: AgentToolTrackingController] = [:] private let windowID: Int private let runtimeWorkspacePathsProvider: (AgentModeViewModel.TabSession) throws -> CodexRuntimeWorkspacePaths @@ -3643,17 +3646,22 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { notifyOnCompleted: false, deleteDeferredFilesWhenFailureHasNoInFlight: true ) - return - } - let alreadyReported = session.items.last.map { $0.kind == .error && $0.text == message } ?? false - if !alreadyReported { - session.appendItem(AgentChatItem.error(message, sequenceIndex: session.nextSequenceIndex)) + } else { + let alreadyReported = session.items.last.map { $0.kind == .error && $0.text == message } ?? false + if !alreadyReported { + session.appendItem(AgentChatItem.error(message, sequenceIndex: session.nextSequenceIndex)) + } + session.runState = .failed + setRunningStatus(nil, source: nil, session: session) + viewModel?.setAgentRunActive(session.tabID, isActive: false) + viewModel?.requestUIRefresh(tabID: session.tabID, urgent: true) + viewModel?.scheduleSave(for: session.tabID) } - session.runState = .failed - setRunningStatus(nil, source: nil, session: session) - viewModel?.setAgentRunActive(session.tabID, isActive: false) - viewModel?.requestUIRefresh(tabID: session.tabID, urgent: true) - viewModel?.scheduleSave(for: session.tabID) + #if DEBUG + if let testWorkspaceResolutionFailurePublicationGate { + await testWorkspaceResolutionFailurePublicationGate() + } + #endif } private static func providerStartupFailureMessage(for error: Error) -> String { @@ -3711,6 +3719,13 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { session: AgentModeViewModel.TabSession, controller: any CodexSessionControlling ) async { + guard let activeController = session.codexController, + Self.sameCodexControllerInstance(activeController, controller) + else { + await controller.shutdown() + return + } + cancelCodexThreadNameSync(for: session.tabID) cancelCodexTabScopedControllerTasks(for: session.tabID) clearCodexControllerRuntimeState(for: session) @@ -3725,8 +3740,8 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { session.pendingCodexComputerUseActivation = nil clearCodexPendingInteractions(in: session) clearCodexNativeToolLiveness(session) - await controller.shutdown() await stopCodexToolTrackingAndWait(for: session) + await controller.shutdown() } func ensureCodexNativeSession( @@ -7061,6 +7076,13 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { } #if DEBUG + @_spi(TestSupport) + public func test_setWorkspaceResolutionFailurePublicationGate( + _ gate: (@Sendable () async -> Void)? + ) { + testWorkspaceResolutionFailurePublicationGate = gate + } + @_spi(TestSupport) public func test_handleCodexNativeEvent( _ event: CodexNativeSessionController.Event, diff --git a/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift b/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift index 6d70cddff..d3965c750 100644 --- a/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift +++ b/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift @@ -640,6 +640,84 @@ final class AgentRunWorktreeStartTests: AgentRunWorktreeStartGitSeedTestCase { viewModel.test_codexCoordinator.test_hasCodexToolTracking(for: session.tabID) ) XCTAssertEqual(controller.shutdownCallCount, 1) + + let staleController = ReplacementIdentityFakeCodexController() + session.codexController = staleController + session.codexControllerWorkspacePaths = .worktreeBound( + logicalRootPath: root.path, + validatedWorktreeRootPath: worktree.path + ) + session.runState = .running + session.beginRunAttempt(source: "test.workspace-resolution-publication-race") + let publicationGate = AgentRunWorktreeStartAsyncGate() + viewModel.test_codexCoordinator.test_setWorkspaceResolutionFailurePublicationGate { + await publicationGate.markStartedAndWaitForRelease() + } + + let failureTask = Task { + await viewModel.test_codexCoordinator.ensureCodexNativeSession(session: session) + } + await publicationGate.waitUntilStarted() + + let shutdownGate = AgentRunWorktreeStartAsyncGate() + let replacementController = ReplacementIdentityFakeCodexController(shutdownGate: shutdownGate) + let replacementRunID = UUID() + session.codexController = replacementController + session.codexControllerWorkspacePaths = .worktreeBound( + logicalRootPath: root.path, + validatedWorktreeRootPath: worktree.path + ) + session.codexEventTask = Task { + try? await Task.sleep(nanoseconds: 5_000_000_000) + } + session.codexEventTaskRunID = replacementRunID + session.pendingCodexComputerUseActivation = .init(id: UUID(), createdAt: Date()) + viewModel.test_codexCoordinator.test_installCodexToolTrackingPlaceholder( + for: session.tabID + ) + + await publicationGate.release() + await failureTask.value + viewModel.test_codexCoordinator.test_setWorkspaceResolutionFailurePublicationGate(nil) + + XCTAssertEqual(controllerIdentity(in: session), ObjectIdentifier(replacementController)) + XCTAssertNotNil(session.codexControllerWorkspacePaths) + XCTAssertNotNil(session.codexEventTask) + XCTAssertEqual(session.codexEventTaskRunID, replacementRunID) + XCTAssertNotNil(session.pendingCodexComputerUseActivation) + XCTAssertTrue( + viewModel.test_codexCoordinator.test_hasCodexToolTracking(for: session.tabID) + ) + XCTAssertEqual(staleController.shutdownCallCount, 1) + XCTAssertEqual(replacementController.shutdownCallCount, 0) + + let matchingCleanupTask = Task { + await viewModel.test_codexCoordinator.ensureCodexNativeSession(session: session) + } + await shutdownGate.waitUntilStarted() + XCTAssertFalse( + viewModel.test_codexCoordinator.test_hasCodexToolTracking(for: session.tabID) + ) + + let successorController = ReplacementIdentityFakeCodexController() + session.codexController = successorController + viewModel.test_codexCoordinator.test_installCodexToolTrackingPlaceholder( + for: session.tabID + ) + await shutdownGate.release() + await matchingCleanupTask.value + + XCTAssertEqual(replacementController.shutdownCallCount, 1) + XCTAssertEqual(controllerIdentity(in: session), ObjectIdentifier(successorController)) + XCTAssertTrue( + viewModel.test_codexCoordinator.test_hasCodexToolTracking(for: session.tabID) + ) + + await viewModel.test_codexCoordinator.ensureCodexNativeSession(session: session) + XCTAssertEqual(successorController.shutdownCallCount, 1) + XCTAssertFalse( + viewModel.test_codexCoordinator.test_hasCodexToolTracking(for: session.tabID) + ) } func testCodexControllerReplacementKeysOnExecutionChangeAndSurvivesSecondaryBindingChanges() async throws { @@ -4592,8 +4670,10 @@ private final class ReplacementIdentityFakeCodexController: CodexSessionControll private var eventsContinuation: AsyncStream.Continuation? private(set) var shutdownCallCount = 0 private let eventsStream: AsyncStream + private let shutdownGate: AgentRunWorktreeStartAsyncGate? - init() { + init(shutdownGate: AgentRunWorktreeStartAsyncGate? = nil) { + self.shutdownGate = shutdownGate var continuationRef: AsyncStream.Continuation? eventsStream = AsyncStream { continuation in continuationRef = continuation @@ -4611,6 +4691,9 @@ private final class ReplacementIdentityFakeCodexController: CodexSessionControll func shutdown() async { shutdownCallCount += 1 + if let shutdownGate { + await shutdownGate.markStartedAndWaitForRelease() + } eventsContinuation?.finish() eventsContinuation = nil } From 2f995b677a906410ce51e6b388b6184c87d68be3 Mon Sep 17 00:00:00 2001 From: MP Date: Wed, 22 Jul 2026 14:43:38 +0800 Subject: [PATCH 16/21] test: widen process termination fixture deadline --- .../RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift b/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift index 35330faee..de02a471d 100644 --- a/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift +++ b/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift @@ -519,7 +519,7 @@ final class ProcessTerminationExitStatusTests: XCTestCase { XCTAssertEqual(signalOutcome.status.normalizedExitCode, 128 + SIGKILL) } - private func waitForPID(at url: URL, timeout: TimeInterval = 2) async -> pid_t? { + private func waitForPID(at url: URL, timeout: TimeInterval = 10) async -> pid_t? { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if let text = try? String(contentsOf: url, encoding: .utf8), From 759ddfe30e1ea5003f3bb13985814ec0db12abd8 Mon Sep 17 00:00:00 2001 From: MP Date: Wed, 22 Jul 2026 16:35:27 +0800 Subject: [PATCH 17/21] fix(codex): preserve resume rollout path --- .../Codex/AppServer/CodexNativeSessionController.swift | 4 ++++ .../Codex/CodexNativeSessionControllerGoalConfigTests.swift | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift index 8398674b0..5e0754119 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift @@ -1086,10 +1086,14 @@ final class CodexNativeSessionController { await ensureInboundStreamsStarted() let configOverrides = await options.configOverridesProvider() + let pathValue = existing?.rolloutPath let result: [String: Any] if let resumeThreadID { var params: [String: Any] = ["threadId": resumeThreadID] + if let pathValue { + params["path"] = pathValue + } if let model { params["model"] = model } diff --git a/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift b/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift index b4081ef34..0f1af2c65 100644 --- a/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift +++ b/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift @@ -460,7 +460,7 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { let (resumeController, resumeRecordURL) = try await makeController(options: makeOptions()) let existing = CodexNativeSessionController.SessionRef( conversationID: " existing-thread ", - rolloutPath: "/tmp/existing-thread.jsonl", + rolloutPath: nil, model: nil, reasoningEffort: "high" ) From 106ec69ef9baccd0335bcf33936b13c8ddacf5c5 Mon Sep 17 00:00:00 2001 From: MP Date: Wed, 22 Jul 2026 17:04:07 +0800 Subject: [PATCH 18/21] chore: retrigger CI From 7cc60fd3fbc5b341268d69902428d42c14202737 Mon Sep 17 00:00:00 2001 From: SB Date: Wed, 22 Jul 2026 19:42:34 +0900 Subject: [PATCH 19/21] fix: fence Codex startup after shutdown --- .../Fixtures/test-suite-contract-ledger.tsv | 1 + .../AppServer/CodexAppServerClient.swift | 26 +++++- .../CodexNativeSessionController.swift | 4 - ...CodexAppServerClientProcessExitTests.swift | 80 +++++++++++++++++++ ...tiveSessionControllerGoalConfigTests.swift | 4 +- 5 files changed, 105 insertions(+), 10 deletions(-) diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index 032cb7142..9dc565b8a 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -1068,6 +1068,7 @@ root/RepoPromptTests.CodexAppServerClientProcessExitTests/testExplicitStopWinsOv root/RepoPromptTests.CodexAppServerClientProcessExitTests/testListModelsRetriesTypedProcessExitOnceOnFreshProcess root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testListModelsRetriesTypedProcessExitOnceOnFreshProcess AgentMode/Codex codex.app_server.model_list.typed_exit_retry model_list,process_exit,retry,fresh_process subprocess_lifecycle_regression root_swiftpm routine 1 exit_then_model_codex_fixture A typed processExited failure during model/list receives one fresh-process retry and returns the successful replacement process model response. Typed exit recovery could regress from the established EOF recovery behavior and leave model discovery unavailable after a transient app-server exit. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 typed-exit model-list recovery coverage. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testNilLaunchDirectoryUsesCLIProcessConfigurationDefaultInExitEvidence root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testNilLaunchDirectoryUsesCLIProcessConfigurationDefaultInExitEvidence AgentMode/Codex codex.app_server.launch_directory.default launch_directory,temp_directory,process_cwd,exit_evidence subprocess_lifecycle_regression root_swiftpm routine 1 working_directory_exit_codex_fixture A nil process launch directory records the CLIProcessConfiguration temp default, the child reports the same canonical cwd, and typed exit evidence preserves exited(41). The app-server could inherit the app cwd while diagnostics claim a different launch contract. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 App-server launch-directory authority contract. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStaleObservedExitCannotMutateReplacementGeneration root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStaleObservedExitCannotMutateReplacementGeneration AgentMode/Codex codex.app_server.lifecycle.stale_generation_exit_rejection generation,observer_identity,replacement async_concurrency_lifecycle root_swiftpm routine 1 persistent_codex_fixture A delivered old-generation exit leaves the replacement PID, generation, running state, and termination reason unchanged. Late observer callbacks could poison a replacement transport. subprocess;filesystem;actor test_case+fixture_cleanup retain 0 WI-2 generation-safety coverage. +root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStopDuringPrepublicationObserverSettlementPreventsReplacementSpawn root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStopDuringPrepublicationObserverSettlementPreventsReplacementSpawn AgentMode/Codex codex.app_server.lifecycle.stop_observer_settlement_spawn_fence stop,observer_settlement,startup_cancellation,pre_spawn,termination_retirement async_concurrency_lifecycle root_swiftpm routine 1 persistent_codex_fixture,ChildExitOutcomePublicationGate A stop during pre-publication observer settlement waits for the winning settlement and revokes the concurrent replacement start; releasing settlement afterward cannot produce a second process spawn or live transport. A replacement start waiting on observer settlement could retain startup authority past stop and spawn an app-server after shutdown. subprocess;filesystem;actor;async_wait test_case+fixture_cleanup retain 0 WI-2 pre-publication settlement post-stop spawn-fence coverage. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStopDuringRestartPreparationPreventsSpawnAfterReturn root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStopDuringRestartPreparationPreventsSpawnAfterReturn AgentMode/Codex codex.app_server.lifecycle.stop_restart_spawn_fence stop,restart,startup_cancellation,pre_spawn,termination_retirement async_concurrency_lifecycle root_swiftpm routine 1 persistent_codex_fixture,ProcessSpawnPreparationGate A stop during blocked restart preparation returns only after revoking startup authority; releasing preparation afterward cannot produce a second process spawn or live transport. A settled teardown task could let stop return without cancelling a restart, allowing an app-server to spawn and survive after shutdown. subprocess;filesystem;actor;async_wait test_case+fixture_cleanup retain 0 WI-2 restart pre-spawn shutdown regression coverage. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStartupEOFReturnsTypedExitWithSettledBoundedStderr root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStartupEOFReturnsTypedExitWithSettledBoundedStderr AgentMode/Codex codex.app_server.process_exit.typed_stderr_evidence stdout_eof,exit_status,terminal_probe,observer_join,root_signal_window,stderr_tail,truncation,descendant_stderr,family_cleanup,first_claim,shared_completion subprocess_lifecycle_regression root_swiftpm routine 1 early_exit_codex_fixture,ChildExitOutcomePublicationGate A publication gate holds beyond the diagnostic window after pre-reap root-PID signaling closure, forcing the non-destructive terminal probe and sole-observer join before processExited returns the exact final 8 KiB raw stderr suffix; a losing stop remains pending until the winning settlement completes. Startup EOF could signal a reaped or reused PID, collapse a real exit into processNotRunning, consume status with a second reap, snapshot incomplete descendant-held stderr, or let duplicate lifecycle callers return before teardown completes. subprocess;filesystem;async_wait;dispatch_wait test_case+fixture_cleanup retain 0 Deterministic kernel-boundary signal closure, terminal-probe arbitration, bounded startup diagnostics, descendant cleanup, and shared-completion coverage. root/RepoPromptTests.CodexAppServerClientProcessExitTests/testStartupSignalExitKeepsSignalSemanticsAndOmitsEmptyStderr root Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift RepoPromptTests.CodexAppServerClientProcessExitTests testStartupSignalExitKeepsSignalSemanticsAndOmitsEmptyStderr AgentMode/Codex codex.app_server.process_exit.signal_evidence signal,empty_stderr,typed_exit subprocess_lifecycle_regression root_swiftpm routine 1 early_exit_codex_fixture A SIGKILL startup exit remains uncaughtSignal(SIGKILL), retains empty raw stderr, and omits stderr from rendered diagnostics. Signal exits could be normalized into ambiguous integer codes or render empty diagnostic noise. subprocess;filesystem;async_wait test_case+fixture_cleanup retain 0 WI-2 typed exit-evidence coverage. diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift index a4a6468e2..30094c261 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift @@ -380,6 +380,9 @@ actor CodexAppServerClient { /// to the correct transport instance — prevents a stale task from killing a /// newly started process. private var transportGeneration: UInt64 = 0 + /// Actor-confined authority for startup work. Explicit or emergency shutdown + /// revokes every invocation that began before the shutdown request. + private var startupAuthorityEpoch: UInt64 = 0 private var startupTask: (id: UUID, task: Task)? private var expectedAgentPIDRegistration: ExpectedAgentPIDRegistration? private var registeredExpectedAgentPID: RegisteredExpectedAgentPID? @@ -529,10 +532,12 @@ actor CodexAppServerClient { } func startIfNeeded() async throws { + let authority = startupAuthorityEpoch if let termination = transportTerminationTask, termination.generation == transportGeneration { await termination.task.value + try ensureStartupAuthority(authority) } if let existingStartupTask = startupTask?.task { return try await existingStartupTask.value @@ -548,11 +553,13 @@ actor CodexAppServerClient { flushStdout: false, fallbackReason: .livenessCheckFailed(method: nil) ) + try ensureStartupAuthority(authority) } } + try ensureStartupAuthority(authority) let startupID = UUID() let task = Task { - try await self.performStartupIfNeeded() + try await self.performStartupIfNeeded(startupAuthority: authority) } startupTask = (id: startupID, task: task) do { @@ -569,6 +576,7 @@ actor CodexAppServerClient { } func stop() async { + startupAuthorityEpoch &+= 1 startupTask?.task.cancel() startupTask = nil if let termination = transportTerminationTask, @@ -920,6 +928,7 @@ actor CodexAppServerClient { } private func emergencyTerminateTransportForDeinit() { + startupAuthorityEpoch &+= 1 startupTask?.task.cancel() startupTask = nil stdoutChunkChannel?.finish() @@ -1240,11 +1249,19 @@ actor CodexAppServerClient { isInitialized = true } - private func performStartupIfNeeded() async throws { + private func ensureStartupAuthority(_ authority: UInt64) throws { + guard authority == startupAuthorityEpoch else { + throw CancellationError() + } + } + + private func performStartupIfNeeded(startupAuthority: UInt64) async throws { do { + try ensureStartupAuthority(startupAuthority) if activeTransport == nil { - try await startProcess() + try await startProcess(startupAuthority: startupAuthority) } + try ensureStartupAuthority(startupAuthority) try await initializeIfNeeded() } catch is CancellationError { if let termination = transportTerminationTask, @@ -1257,7 +1274,7 @@ actor CodexAppServerClient { } } - private func startProcess() async throws { + private func startProcess(startupAuthority: UInt64) async throws { let environmentResult = await ProcessEnvironmentBuilder.build( ProcessEnvironmentRequest( purpose: .codexAppServer, @@ -1287,6 +1304,7 @@ actor CodexAppServerClient { ) try await processSpawnPreparation() try Task.checkCancellation() + try ensureStartupAuthority(startupAuthority) let spawned = try ProcessLauncher.spawn( command: resolution.resolvedCommand, arguments: args, diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift index 5e0754119..8398674b0 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift @@ -1086,14 +1086,10 @@ final class CodexNativeSessionController { await ensureInboundStreamsStarted() let configOverrides = await options.configOverridesProvider() - let pathValue = existing?.rolloutPath let result: [String: Any] if let resumeThreadID { var params: [String: Any] = ["threadId": resumeThreadID] - if let pathValue { - params["path"] = pathValue - } if let model { params["model"] = model } diff --git a/Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift b/Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift index fcf16f1c9..63ff27bbc 100644 --- a/Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift +++ b/Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift @@ -395,6 +395,86 @@ final class CodexAppServerClientProcessExitTests: XCTestCase { await client.stop() } + func testStopDuringPrepublicationObserverSettlementPreventsReplacementSpawn() async throws { + let directory = try makeTemporaryDirectory() + let recordURL = directory.appendingPathComponent("requests.jsonl") + let spawnCountURL = directory.appendingPathComponent("spawn-count") + let executable = try makePersistentServer( + in: directory, + recordURL: recordURL, + spawnCountURL: spawnCountURL + ) + let outcomePublicationGate = ChildExitOutcomePublicationGate() + let client = try await makeClient( + executable: executable, + launchDirectory: directory, + timeout: 5, + processExitObserverFactory: { pid in + ChildProcessExitObserver( + pid: pid, + beforePublishingOutcome: { outcomePublicationGate.hold($0) } + ) + } + ) + let replacementStartCleanup = ThrowingTaskCleanup() + addTeardownBlock { + outcomePublicationGate.release() + await client.stop() + await replacementStartCleanup.finish() + } + try await client.startIfNeeded() + try await waitUntil("initial spawn count", timeout: 2) { + (try? String(contentsOf: spawnCountURL, encoding: .utf8)) == "1" + } + + let initialPIDValue = await client.debugProcessID() + let initialPID = try XCTUnwrap(initialPIDValue) + XCTAssertEqual(Darwin.kill(initialPID, SIGKILL), 0) + let deadline = CodexProcessExitTestDeadline(timeout: 5) + guard await outcomePublicationGate.waitUntilHolding(timeout: deadline.remaining) else { + throw WaitUntilError.timedOut("child exit outcome publication gate") + } + try await waitUntil("stdout EOF observer settlement join", timeout: deadline.remaining) { + await client.debugTerminalObserverJoinCount() >= 1 + } + let joinCountBeforeReplacementStart = await client.debugTerminalObserverJoinCount() + + let replacementStart = Task { + try await client.startIfNeeded() + } + await replacementStartCleanup.track(replacementStart) + try await waitUntil("replacement-start observer settlement join", timeout: deadline.remaining) { + await client.debugTerminalObserverJoinCount() > joinCountBeforeReplacementStart + } + + let stopCompletion = CompletionFlag() + let stop = Task { + await client.stop() + await stopCompletion.markComplete() + } + try await waitUntil("explicit stop transport claim", timeout: deadline.remaining) { + await client.debugLastTransportTerminationReason() == .explicitStop + } + let stopCompletedBeforeSettlementRelease = await stopCompletion.isComplete + XCTAssertFalse(stopCompletedBeforeSettlementRelease) + outcomePublicationGate.release() + await stop.value + + do { + try await replacementStart.value + XCTFail("The pre-publication start must not spawn after stop") + } catch is CancellationError { + // Expected: stop revoked this invocation while it was joining settlement. + } catch { + XCTFail("Expected replacement-start cancellation, got \(error)") + } + XCTAssertEqual(try String(contentsOf: spawnCountURL, encoding: .utf8), "1") + let isRunning = await client.debugIsProcessRunning() + let processObserver = await client.debugProcessExitObserver() + XCTAssertFalse(isRunning) + XCTAssertNil(processObserver) + } + func testStopDuringRestartPreparationPreventsSpawnAfterReturn() async throws { let directory = try makeTemporaryDirectory() let recordURL = directory.appendingPathComponent("requests.jsonl") diff --git a/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift b/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift index 0f1af2c65..130289e31 100644 --- a/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift +++ b/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift @@ -351,7 +351,7 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { let resumeParams = try recordedParams(for: "thread/resume", at: recordURL) XCTAssertEqual(resumeParams["cwd"] as? String, worktreeRoot.path) XCTAssertEqual(resumeParams["threadId"] as? String, "existing-thread") - XCTAssertEqual(resumeParams["path"] as? String, "/tmp/existing-thread.jsonl") + XCTAssertNil(resumeParams["path"]) let turnParams = try recordedRequests(for: "turn/start", at: recordURL) .map { try XCTUnwrap($0["params"] as? [String: Any]) } @@ -460,7 +460,7 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { let (resumeController, resumeRecordURL) = try await makeController(options: makeOptions()) let existing = CodexNativeSessionController.SessionRef( conversationID: " existing-thread ", - rolloutPath: nil, + rolloutPath: "/tmp/existing-thread.jsonl", model: nil, reasoningEffort: "high" ) From f53a5ef52849dfe8554979f175324388105c1288 Mon Sep 17 00:00:00 2001 From: SB Date: Wed, 22 Jul 2026 21:18:10 +0900 Subject: [PATCH 20/21] fix: preserve primary root for git-style selectors --- .../Fixtures/test-suite-contract-ledger.tsv | 2 +- .../AgentMCPStartWorktreeCoordinator.swift | 2 +- .../MCP/AgentRunWorktreeStartTests.swift | 35 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index 9dc565b8a..77124a15d 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -385,7 +385,7 @@ root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentRunStartInheritsRoutedW root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentRunStartInheritsRoutedWorktreeFromFormerMCPParentWithoutControlContext root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentRunStartInheritsRoutedWorktreeFromFormerMCPParentWithoutControlContext MCP agent.start.worktree-inheritance.former_mcp_parent routed_source,sticky_mcp_origin,nil_control_context,default_inheritance,effective_path behavioral_regression root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture A routed child start from a hydrated parent with persisted worktree bindings, sticky isMCPOriginated=true, and no live MCP control context inherits the binding in the start response and effective workspace path. A former MCP-created parent later driven from the UI could permanently fail inherited child starts with an unavailable-control-context error despite valid persisted bindings. 0.750000 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Issue #383 QA follow-up regression for sticky MCP-origin sessions after control context release. root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartExplicitWorktreeIDOverridesInheritedBindingAcrossRunExploreAndExplicitTab root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentStartExplicitWorktreeIDOverridesInheritedBindingAcrossRunExploreAndExplicitTab MCP agent.start.explicit-worktree-override existing_worktree_id,created_worktree,inherit_true,inherit_false,explore,explicit_tab behavioral_regression root_swiftpm routine 5 AgentRunWorktreeStartLifecycleFixture Explicit existing-worktree and app-created Agent Run routes contain only the requested binding regardless of inherit_worktree, Explore preserves its explicit binding, explicit-tab routing also suppresses the captured parent binding without creating a parent relationship, and spawned children retain their expected parent relationships. Automatic or frozen-source inheritance could leak a parent checkout alongside or instead of an explicitly selected or created child worktree on Run, Explore, or explicit-tab routes. 3.177000 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture consolidated_replacement 0 Renamed from root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartExplicitWorktreeIDOverridesInheritedBindingAcrossRunAndExplore; retains replacements root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentRunStartExplicitWorktreeIDOverridesInheritanceSetting and root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentExploreStartExplicitWorktreeIDOverridesInheritedBindingBeforeProviderStart; strengthened with explicit-tab override precedence. root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartWorktreeSelectorRejectsSecondaryGitRepoWhenPrimaryRootIsNonGit root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentStartWorktreeSelectorRejectsSecondaryGitRepoWhenPrimaryRootIsNonGit MCP agent.start.worktree-selector.non-git-primary-rejection non_git_primary,secondary_git,worktree_id,fail_closed,no_target_mutation behavioral_regression root_swiftpm routine 1 AgentRunWorktreeStartLifecycleFixture A shorthand worktree ID targeting a secondary Git repository is rejected by the primary-root runtime boundary when the declared primary workspace root is non-Git, without creating a target tab. Fallback repository resolution could pair a secondary Git checkout with the non-Git primary logical root and bypass the primary-only provider runtime restriction. suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Primary-root fail-closed regression for implicit worktree repository selection. -root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartWorktreeSelectorsDefaultToWorkspacePrimaryRepoWhenRootsLoadedOutOfOrder root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentStartWorktreeSelectorsDefaultToWorkspacePrimaryRepoWhenRootsLoadedOutOfOrder MCP agent.start.worktree-selector.primary-root-default multi_root,inverse_root_order,primary_workspace,symlink_alias,worktree_id,branch_selector,implicit_repo_root,explicit_repo_root behavioral_regression root_swiftpm routine 6 AgentRunWorktreeStartLifecycleFixture Direct and symlink-aliased declared-primary paths promote the primary root ahead of inverse discovery order, then worktree ID and branch selectors bind that repository and logical root for implicit and explicit selection. Using file-index load order or lexical path identity could reject a valid primary-root worktree before provider startup or associate it with the wrong logical root. suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Multi-root shorthand selector regression for issue #554. +root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartWorktreeSelectorsDefaultToWorkspacePrimaryRepoWhenRootsLoadedOutOfOrder root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentStartWorktreeSelectorsDefaultToWorkspacePrimaryRepoWhenRootsLoadedOutOfOrder MCP agent.start.worktree-selector.primary-root-default multi_root,inverse_root_order,primary_workspace,symlink_alias,worktree_id,branch_selector,implicit_repo_root,explicit_repo_root,nested_primary,same_repository,parent_loaded_first,git_style_repo_root,repository_relative_binding behavioral_regression root_swiftpm routine 7 AgentRunWorktreeStartLifecycleFixture Direct and symlink-aliased declared-primary paths promote the primary root ahead of inverse discovery order; worktree ID and branch selectors bind that repository for implicit and explicit selection; and a git-style repository selector preserves a declared nested primary root loaded after its parent while projecting the repository-relative physical binding. Using file-index load order or lexical path identity could reject a valid primary-root worktree before provider startup, associate it with the wrong logical root, or collapse a nested primary root to an earlier-loaded parent from the same repository. suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Multi-root shorthand selector regression for issue #554, strengthened for PR #568 nested-primary logical-root selection. root/RepoPromptTests.AgentRunWorktreeStartTests/testAgentStartWorktreeSelectorsRejectCrossRepositoryMatchesForImplicitAndExplicitPrimaryRoot root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testAgentStartWorktreeSelectorsRejectCrossRepositoryMatchesForImplicitAndExplicitPrimaryRoot MCP agent.start.worktree-selector.repository-authority multi_root,worktree_id,branch_selector,implicit_repo_root,explicit_repo_root,cross_repository,fail_closed,no_target_mutation behavioral_regression root_swiftpm routine 4 AgentRunWorktreeStartLifecycleFixture Worktree ID and branch selectors targeting a secondary repository are rejected for both implicit and explicit primary-root selection, leaving tab and session counts unchanged. A globally unique foreign worktree selector could resolve across workspace repositories and be persisted under the selected primary logical root. suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Existing-worktree selector repository-authority regression for issue #554. root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionMaterializesSessionWorktreeWithoutCodemapWork root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testBindingTransitionMaterializesSessionWorktreeWithoutCodemapWork MCP agent.worktree.binding_transition.zero_codemap_materialization initial_binding,watcher_ownership,idempotent_materialization,zero_codemap_work,last_release_cleanup deterministic_integration root_swiftpm routine 4 AgentRunWorktreeStartLifecycleFixture Initial binding materializes one watched physical root with zero legacy scans, modern demands, freezes, or initialization tasks; repeated projection reuses the owner/root without recrawl; unbind releases the final claim. Worktree binding could eagerly start codemap work, duplicate catalog ownership, or leak the final root claim. 0.064500 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture retain 0 Milestone D replaces eager session-worktree codemap initialization with neutral materialization and zero-work assertions. root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionValidatesAndMaterializesChangedSecondaryBindingWithoutRestartingPrimaryIdentity root Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift RepoPromptTests.AgentRunWorktreeStartTests testBindingTransitionValidatesAndMaterializesChangedSecondaryBindingWithoutRestartingPrimaryIdentity MCP agent.worktree.binding-transition.secondary behavioral_regression root_swiftpm routine 2 AgentRunWorktreeStartLifecycleFixture Unavailable secondary binding rejects atomically; valid changed secondary materializes both physical roots without restarting provider or Codex identity high 0.804500 suite_owned_temp_roots;suite_owned_git_sandboxes;suite_owned_immutable_git_seed;awaited_window_teardown GlobalSettingsStore.mcpAutoStart;WindowStatesManager;ServerNetworkManager.bootstrapSocket;AgentRunWorktreeStartGitSeedOwner AgentRunWorktreeStartGitSeedOwner;AgentRunWorktreeStartLifecycleFixture consolidated_replacement 0 replaces root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionMaterializesChangedSecondaryBindingWithoutRestartingUnchangedPrimaryIdentity; root/RepoPromptTests.AgentRunWorktreeStartTests/testBindingTransitionRejectsUnavailableSecondaryBindingEvenWhenPrimaryIdentityIsUnchanged diff --git a/Sources/RepoPrompt/Infrastructure/MCP/Agent/AgentMCPStartWorktreeCoordinator.swift b/Sources/RepoPrompt/Infrastructure/MCP/Agent/AgentMCPStartWorktreeCoordinator.swift index 44171ed95..a0505bcc2 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/Agent/AgentMCPStartWorktreeCoordinator.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/Agent/AgentMCPStartWorktreeCoordinator.swift @@ -463,7 +463,7 @@ struct AgentMCPStartWorktreeCoordinator { let logicalRoot = try await logicalRoot( for: repo, explicitLogicalRoot: explicitLogicalRoot, - visibleRoots: visibleRoots + visibleRoots: discoveryRoots ) return RepositoryContext( repo: repo, diff --git a/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift b/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift index d3965c750..e53b592f5 100644 --- a/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift +++ b/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift @@ -2680,6 +2680,41 @@ final class AgentRunWorktreeStartTests: AgentRunWorktreeStartGitSeedTestCase { XCTAssertEqual(binding["logical_root_path"]?.stringValue, primaryFixture.repo.path, testCase.label) XCTAssertEqual(binding["worktree_root_path"]?.stringValue, descriptor.path, testCase.label) } + + let nestedFixture = try makeGitFixture() + let nestedPrimary = nestedFixture.repo.appendingPathComponent("NestedPrimary", isDirectory: true) + try FileManager.default.createDirectory(at: nestedPrimary, withIntermediateDirectories: true) + let nestedWindow = try await lifecycleFixture.makeWindow( + roots: [nestedPrimary, nestedFixture.repo], + loadRoots: false + ) + let loadedParent = try await WorkspaceRootLoadTestSupport.loadRootMatchingCurrentFileSystemSettings( + in: nestedWindow, + path: nestedFixture.repo.path + ) + let loadedNestedPrimary = try await WorkspaceRootLoadTestSupport.loadRootMatchingCurrentFileSystemSettings( + in: nestedWindow, + path: nestedPrimary.path + ) + let loadedRoots = await nestedWindow.promptManager.workspaceFileContextStore.rootRefs(scope: .visibleWorkspace) + XCTAssertEqual(loadedRoots.map(\.id), [loadedParent.id, loadedNestedPrimary.id]) + XCTAssertEqual(nestedWindow.workspaceManager.activeWorkspace?.repoPaths.first, nestedPrimary.path) + + let nestedService = makeAgentRunStartService(window: nestedWindow, sourceTabID: nil) + let nestedValue = try await nestedService.execute(args: [ + "op": .string("start"), + "message": .string("git-style repository selector preserves declared nested primary root"), + "detach": .bool(true), + "timeout": .int(0), + "worktree": .string("@main"), + "worktree_repo_root": .string("@main") + ]) + let nestedObject = try XCTUnwrap(nestedValue.objectValue) + let nestedBindings = try XCTUnwrap(nestedObject["worktree_bindings"]?.arrayValue) + let nestedBinding = try XCTUnwrap(nestedBindings.first?.objectValue) + XCTAssertEqual(nestedBindings.count, 1) + XCTAssertEqual(nestedBinding["logical_root_path"]?.stringValue, nestedPrimary.path) + XCTAssertEqual(nestedBinding["worktree_root_path"]?.stringValue, nestedPrimary.path) } func testAgentStartWorktreeSelectorsRejectCrossRepositoryMatchesForImplicitAndExplicitPrimaryRoot() async throws { From 21786ec0e46555797fa5e4133c0948f8fc3db81d Mon Sep 17 00:00:00 2001 From: SB Date: Wed, 22 Jul 2026 21:48:22 +0900 Subject: [PATCH 21/21] test: stabilize nested primary selector coverage --- .../MCP/AgentRunWorktreeStartTests.swift | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift b/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift index e53b592f5..d607523e0 100644 --- a/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift +++ b/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift @@ -2684,10 +2684,24 @@ final class AgentRunWorktreeStartTests: AgentRunWorktreeStartGitSeedTestCase { let nestedFixture = try makeGitFixture() let nestedPrimary = nestedFixture.repo.appendingPathComponent("NestedPrimary", isDirectory: true) try FileManager.default.createDirectory(at: nestedPrimary, withIntermediateDirectories: true) + let nestedBranch = "feature/nested-primary-selector-\(nestedFixture.suffix)" + let nestedWorktree = nestedFixture.sandbox.appendingPathComponent("nested-primary-worktree", isDirectory: true) + try runGit( + ["worktree", "add", "-b", nestedBranch, nestedWorktree.path, "HEAD"], + cwd: nestedFixture.repo + ) + let nestedWorktreePrimary = nestedWorktree.appendingPathComponent("NestedPrimary", isDirectory: true) + try FileManager.default.createDirectory(at: nestedWorktreePrimary, withIntermediateDirectories: true) + let nestedWindow = try await lifecycleFixture.makeWindow( roots: [nestedPrimary, nestedFixture.repo], loadRoots: false ) + _ = try await nestedWindow.workspaceManager.awaitWorkspaceSearchReadiness(timeout: .seconds(2)) + let nestedStore = nestedWindow.promptManager.workspaceFileContextStore + for root in await nestedStore.rootRefs(scope: .visibleWorkspace) { + await nestedStore.unloadRoot(id: root.id) + } let loadedParent = try await WorkspaceRootLoadTestSupport.loadRootMatchingCurrentFileSystemSettings( in: nestedWindow, path: nestedFixture.repo.path @@ -2696,7 +2710,7 @@ final class AgentRunWorktreeStartTests: AgentRunWorktreeStartGitSeedTestCase { in: nestedWindow, path: nestedPrimary.path ) - let loadedRoots = await nestedWindow.promptManager.workspaceFileContextStore.rootRefs(scope: .visibleWorkspace) + let loadedRoots = await nestedStore.rootRefs(scope: .visibleWorkspace) XCTAssertEqual(loadedRoots.map(\.id), [loadedParent.id, loadedNestedPrimary.id]) XCTAssertEqual(nestedWindow.workspaceManager.activeWorkspace?.repoPaths.first, nestedPrimary.path) @@ -2706,7 +2720,7 @@ final class AgentRunWorktreeStartTests: AgentRunWorktreeStartGitSeedTestCase { "message": .string("git-style repository selector preserves declared nested primary root"), "detach": .bool(true), "timeout": .int(0), - "worktree": .string("@main"), + "worktree": .string("@branch:\(nestedBranch)"), "worktree_repo_root": .string("@main") ]) let nestedObject = try XCTUnwrap(nestedValue.objectValue) @@ -2714,7 +2728,7 @@ final class AgentRunWorktreeStartTests: AgentRunWorktreeStartGitSeedTestCase { let nestedBinding = try XCTUnwrap(nestedBindings.first?.objectValue) XCTAssertEqual(nestedBindings.count, 1) XCTAssertEqual(nestedBinding["logical_root_path"]?.stringValue, nestedPrimary.path) - XCTAssertEqual(nestedBinding["worktree_root_path"]?.stringValue, nestedPrimary.path) + XCTAssertEqual(nestedBinding["worktree_root_path"]?.stringValue, nestedWorktreePrimary.path) } func testAgentStartWorktreeSelectorsRejectCrossRepositoryMatchesForImplicitAndExplicitPrimaryRoot() async throws {