diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index 9f25a181f..77124a15d 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -384,10 +384,18 @@ 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,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 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,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 +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. @@ -405,6 +413,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 @@ -667,9 +676,18 @@ 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/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/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,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/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 @@ -1044,6 +1062,20 @@ 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/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. +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. 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 @@ -1137,7 +1169,7 @@ root/RepoPromptTests.CodexMCPRoutingReadinessTests/testCancellationDuringRouting root/RepoPromptTests.CodexMCPRoutingReadinessTests/testResumeFallbackToFreshRoutingFailureUsesStartPrefixAndDeduplicates root Tests/RepoPromptTests/AgentMode/Codex/CodexMCPRoutingReadinessTests.swift RepoPromptTests.CodexMCPRoutingReadinessTests testResumeFallbackToFreshRoutingFailureUsesStartPrefixAndDeduplicates AgentMode codex.agent_mode.actual_startup_disposition_routing_failure resume,fresh_fallback,routing_gate,error_prefix,dedup,stale_disposition async_state_machine root_swiftpm routine 2 RoutingReadinessFakeCodexController,TerminalPublicationRecorder A missing-rollout resume retries as a fresh start, then an unrouted fallback records exactly one start-prefixed readiness failure and one terminal publication; failure classification also recognizes that fresh-start prefix even if the stored disposition is stale-resumed. A successful fresh fallback could be mislabeled as a resume failure, or stale disposition could bypass generic native-start failure deduplication and publish a second terminal failure. global_connection_gate;server_network_manager;async_tasks mcp_shared_server;ServerNetworkManager MCPSharedServerTestLease retain 0 Actual startup disposition owns user-visible routing failure classification, while dedup remains a superset of both native failure prefixes. 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_prefix async_state_machine root_swiftpm routine 1 RoutingReadinessFakeCodexController,TerminalPublicationRecorder An unrouted reconnecting Codex child never dispatches its first turn, returns a failed outcome, publishes exactly one failed terminal state, and records exactly one readiness error with the Codex native resume failed prefix and no fresh-start prefix. An unrouted resume could reach its first turn, omit terminal failure publication, or mislabel its readiness failure as a fresh native start. 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 the resume-specific error prefix. +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. @@ -1185,11 +1217,14 @@ 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/testSchemaAlignedThreadRequestsOmitUndeclaredFieldsAndAcceptMissingGoal root Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift RepoPromptTests.CodexNativeSessionControllerGoalConfigTests testSchemaAlignedThreadRequestsOmitUndeclaredFieldsAndAcceptMissingGoal AgentMode codex.app_server.request_conformance thread_start,thread_resume,thread_goal,request_shape protocol_contract root_swiftpm routine 5 Thread start and resume omit undeclared effort and path fields, missing goals remain nil, and blocked and usage-limited goal statuses decode exactly. Schema drift could send rejected thread parameters or mis-handle missing and terminal goal statuses during Codex session startup or resume. 2.397000 test_case retain 0 Codex app-server schema gate boundary 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. 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/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift index 252d1dece..36050299d 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 @@ -212,9 +212,12 @@ 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 workspacePathProvider: (AgentModeViewModel.TabSession) throws -> String? + private let runtimeWorkspacePathsProvider: (AgentModeViewModel.TabSession) throws -> CodexRuntimeWorkspacePaths private let codexControllerFactory: CodexControllerFactory private let connectionPolicyInstaller: ConnectionPolicyInstaller private let shouldManageCodexTooling: Bool @@ -328,7 +331,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, @@ -348,7 +351,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 @@ -2659,12 +2662,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 @@ -3532,6 +3531,47 @@ 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 + } + + /// 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, @@ -3551,27 +3591,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." ) - session.codexController = nil - session.codexControllerPermissionProfile = nil - session.codexControllerTaskLabelKind = nil - session.codexControllerWorkspacePath = nil - session.codexControllerFeatureState = nil if !preserveRunID { session.runID = nil } @@ -3652,17 +3678,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 { @@ -3722,6 +3753,35 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { viewModel?.scheduleSave(for: session.tabID) } + private func retireCodexControllerAfterWorkspaceResolutionFailure( + 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) + 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) + clearCodexNativeToolLiveness(session) + await stopCodexToolTrackingAndWait(for: session) + await controller.shutdown() + } + func ensureCodexNativeSession( session: AgentModeViewModel.TabSession, policyAlreadyInstalled: Bool = false, @@ -3765,11 +3825,18 @@ 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 { + await retireCodexControllerAfterWorkspaceResolutionFailure( + session: session, + controller: controllerToShutdown + ) + } return } let wantsGoalSupport = CodexGoalSupport.isEnabled @@ -3796,8 +3863,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, @@ -3840,7 +3909,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { runID, session.tabID, windowID, - runtimeWorkspacePath, + runtimeWorkspacePaths, session.permissionProfile, currentTaskLabelKind, wantsComputerUse @@ -3848,7 +3917,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 } @@ -7054,6 +7123,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, @@ -7124,6 +7200,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( @@ -8128,8 +8214,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 @@ -8158,21 +8243,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() } } - session.codexController = nil - session.codexControllerPermissionProfile = nil - session.codexControllerTaskLabelKind = nil - session.codexControllerWorkspacePath = nil - session.codexControllerFeatureState = nil - session.codexEventTask?.cancel() - session.codexEventTask = nil - session.codexEventTaskRunID = nil - session.codexLastEventAt = nil - resetCodexWatchdogState(session) + clearCodexControllerRuntimeState(for: session) clearCodexNativeToolLiveness(session) stopCodexToolTracking(for: session) } @@ -8238,21 +8312,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) - session.codexController = nil - session.codexControllerPermissionProfile = nil - session.codexControllerTaskLabelKind = nil - session.codexControllerWorkspacePath = nil - session.codexControllerFeatureState = nil + 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 @@ -8306,10 +8368,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() @@ -8325,17 +8384,8 @@ 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 + 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 { 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 3df263329..b8474cb84 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+TabSession.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+TabSession.swift @@ -496,9 +496,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 ef9200aa8..c90126c39 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 @@ -1413,12 +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 (sessionWorkspacePathProvider, codexRuntimeWorkspacePathsProvider) = + Self.makeSessionWorkspaceProviders(fallbackWorkspacePath: codexWorkspacePathProvider) workspacePathProvider = codexWorkspacePathProvider attachmentWorkspaceDirectoryProvider = { [weak workspaceManager] in guard let workspaceManager, workspaceManager.activeWorkspace != nil else { @@ -1426,7 +1422,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( approvalPolicyProvider: { permissionProfile.codexApprovalPolicy }, @@ -1443,7 +1439,7 @@ final class AgentModeViewModel: ObservableObject { runID: runID, tabID: tabID, windowID: windowID, - workspacePath: workspacePath, + workspacePaths: workspacePaths, options: options, clientShutdownBehavior: .stopOnShutdown, expectedMCPClientName: AgentProviderKind.codexExec.mcpClientNameHint @@ -1491,7 +1487,7 @@ final class AgentModeViewModel: ObservableObject { usesProductionAgentDefaultsAndModelPolling = true codexCoordinator = CodexAgentModeCoordinator( windowID: windowID, - workspacePathProvider: sessionWorkspacePathProvider, + runtimeWorkspacePathsProvider: codexRuntimeWorkspacePathsProvider, codexControllerFactory: codexControllerFactory, connectionPolicyInstaller: connectionPolicyInstaller, shouldManageCodexTooling: true, @@ -1640,16 +1636,12 @@ 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 (sessionWorkspacePathProvider, codexRuntimeWorkspacePathsProvider) = + Self.makeSessionWorkspaceProviders(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 @@ -1674,7 +1666,7 @@ final class AgentModeViewModel: ObservableObject { } codexCoordinator = CodexAgentModeCoordinator( windowID: testWindowID, - workspacePathProvider: sessionWorkspacePathProvider, + runtimeWorkspacePathsProvider: codexRuntimeWorkspacePathsProvider, codexControllerFactory: codexControllerFactory, connectionPolicyInstaller: connectionPolicyInstaller, shouldManageCodexTooling: shouldManageCodexTooling, @@ -1843,6 +1835,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()) } @@ -1878,6 +1877,41 @@ 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? + ) 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..30094c261 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 { @@ -166,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) @@ -179,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: @@ -193,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 { @@ -203,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 { @@ -235,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 { @@ -295,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? @@ -311,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. @@ -318,13 +380,23 @@ 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? 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 processExitObserverFactory: @Sendable (pid_t) -> ChildProcessExitObserver private let expectedAgentPIDRegistrar: ExpectedAgentPIDRegistrar + #if DEBUG + private var terminalObserverJoinCount = 0 + #endif deinit { emergencyTerminateTransportForDeinit() @@ -337,10 +409,16 @@ actor CodexAppServerClient { livenessProbe: @escaping @Sendable (SpawnedProcess) -> Bool = { process in 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 } @@ -354,11 +432,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 { @@ -367,7 +445,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, @@ -375,10 +457,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 } @@ -398,20 +480,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,16 +502,11 @@ 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 { + if activeTransport != nil { await terminateTransport(flushStdout: true, reason: .explicitStop) } } @@ -463,27 +532,34 @@ 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 } - 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) ) + 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 { @@ -500,8 +576,15 @@ actor CodexAppServerClient { } func stop() async { + startupAuthorityEpoch &+= 1 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 +614,118 @@ 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 } + let generation = transportGeneration + return installTransportTermination( + generation: generation, + reason: reason, + captureAfterClaim: { lastTransportFailure = requestFailure }, + operation: { client, _ in + await client.completeTransportTermination( + generation: generation, + flushStdout: flushStdout, + requestFailure: requestFailure + ) + } + ) + } + + 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 + } + return installTransportTermination( + generation: generation, + reason: .observedProcessExit(status: status), + captureAfterClaim: { takeRegisteredExpectedAgentPIDForDeferredClear() }, + operation: { client, expectedAgentPIDToClear in + await client.completeObservedProcessExitTermination( + status: status, + observer: observer, + generation: generation, + expectedAgentPIDToClear: expectedAgentPIDToClear + ) + } + ) + } + + /// 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, + captureAfterClaim: () -> ClaimContext, + operation: @escaping @Sendable (CodexAppServerClient, ClaimContext) async -> Void + ) -> Task? { + if let existing = transportTerminationTask, + existing.generation == generation + { + return existing.task + } + guard claimTransportTermination(reason: reason) else { return nil } + let claimContext = captureAfterClaim() + let task = Task { [weak self] in + guard let self else { return } + await operation(self, claimContext) + } + 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 +736,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 +757,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,34 +769,166 @@ 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 activeTransport, + activeTransport.generation == generation, + let observer = activeTransport.exitObservation?.observer + { + 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, + 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() { + startupAuthorityEpoch &+= 1 startupTask?.task.cancel() startupTask = nil stdoutChunkChannel?.finish() @@ -666,15 +969,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,27 +1000,36 @@ 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 { // 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 } @@ -739,9 +1061,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 +1100,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 +1116,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 +1134,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 +1152,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 @@ -919,14 +1249,32 @@ actor CodexAppServerClient { isInitialized = true } - private func performStartupIfNeeded() async throws { - if process == nil { - try await startProcess() + 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(startupAuthority: startupAuthority) + } + try ensureStartupAuthority(startupAuthority) + 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 { + private func startProcess(startupAuthority: UInt64) async throws { let environmentResult = await ProcessEnvironmentBuilder.build( ProcessEnvironmentRequest( purpose: .codexAppServer, @@ -951,34 +1299,76 @@ actor CodexAppServerClient { featurePolicy: config.processFeaturePolicy ) let args = processOverrides + ["app-server"] + let launchDirectory = CLIProcessConfiguration.resolvedWorkingDirectory( + config.processLaunchDirectory + ) + try await processSpawnPreparation() + try Task.checkCancellation() + try ensureStartupAuthority(startupAuthority) let spawned = try ProcessLauncher.spawn( command: resolution.resolvedCommand, arguments: args, environment: environment, - workingDirectory: config.workingDirectory + 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 = processExitObserverFactory(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 +1382,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 +1397,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 +1412,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 +1428,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 +1453,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 +1815,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 +1830,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 +1838,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 +1850,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 +1918,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 +1969,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,10 +1988,30 @@ 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) } + func debugTerminalObserverJoinCount() -> Int { + terminalObserverJoinCount + } + func debugDecodeRecoveryAttempts(generation: UInt64? = nil) -> Int { let key = generation ?? transportGeneration return decodeRecoveryAttemptsByGeneration[key, default: 0] @@ -1631,18 +2037,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/CodexNativeSessionController.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift index bb94c5976..8398674b0 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexNativeSessionController.swift @@ -572,7 +572,9 @@ final class CodexNativeSessionController { private let runID: UUID private let tabID: UUID private let windowID: Int - private let workspacePath: 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? @@ -739,7 +741,7 @@ final class CodexNativeSessionController { runID: UUID, tabID: UUID, windowID: Int, - workspacePath: String?, + workspacePaths: CodexRuntimeWorkspacePaths, options: Options? = nil, clientShutdownBehavior: ClientShutdownBehavior = .none, expectedMCPClientName: String? = nil, @@ -749,7 +751,7 @@ final class CodexNativeSessionController { self.runID = runID self.tabID = tabID self.windowID = windowID - self.workspacePath = workspacePath + self.workspacePaths = workspacePaths self.options = options ?? Self.Options.agentModeDefault() self.clientShutdownBehavior = clientShutdownBehavior self.expectedMCPClientName = expectedMCPClientName @@ -899,7 +901,7 @@ final class CodexNativeSessionController { } private static func makeRawEventLogFileURL( - workspacePath: String?, + executionDirectory: String?, threadID: String ) -> URL? { let defaults = UserDefaults.standard @@ -910,10 +912,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() @@ -940,7 +942,7 @@ final class CodexNativeSessionController { return } guard let fileURL = Self.makeRawEventLogFileURL( - workspacePath: workspacePath, + executionDirectory: workspacePaths.executionDirectory, threadID: threadIdentifier ) else { return @@ -994,7 +996,7 @@ final class CodexNativeSessionController { "runID": runID.uuidString, "tabID": tabID.uuidString, "threadID": threadID ?? "", - "workspacePath": workspacePath ?? "" + "workspacePath": workspacePaths.executionDirectory ?? "" ]) } var record: [String: Any] = [ @@ -1076,7 +1078,7 @@ final class CodexNativeSessionController { .init(clientName: expectedMCPClientName, runID: runID) ) } - await client.updateWorkingDirectory(workspacePath) + await client.updateProcessLaunchDirectory(workspacePaths.processLaunchDirectory) await updateClientProcessLaunchPolicy() // Re-check: the pre-launch setup above has suspension points after the first check. try Task.checkCancellation() @@ -1092,8 +1094,8 @@ final class CodexNativeSessionController { params["model"] = model } Self.addServiceTier(serviceTier, to: ¶ms) - if let workspacePath { - params["cwd"] = workspacePath + if let executionDirectory = workspacePaths.executionDirectory { + params["cwd"] = executionDirectory } if !configOverrides.isEmpty { params["config"] = configOverrides @@ -1118,8 +1120,8 @@ final class CodexNativeSessionController { params["model"] = model } Self.addServiceTier(serviceTier, to: ¶ms) - if let workspacePath { - params["cwd"] = workspacePath + if let executionDirectory = workspacePaths.executionDirectory { + params["cwd"] = executionDirectory } if !configOverrides.isEmpty { params["config"] = configOverrides @@ -1373,8 +1375,8 @@ final class CodexNativeSessionController { params["effort"] = reasoningEffort } Self.addServiceTier(serviceTier, to: ¶ms) - if let workspacePath { - params["cwd"] = workspacePath + if let executionDirectory = workspacePaths.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)") @@ -1390,7 +1392,7 @@ final class CodexNativeSessionController { requestParams["approvalsReviewer"] = options.approvalReviewerProvider().appServerRequestValue requestParams["sandboxPolicy"] = Self.appServerTurnSandboxPolicyPayload( mode: sandboxMode, - workspacePath: workspacePath + 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. @@ -3434,7 +3436,7 @@ final class CodexNativeSessionController { runID: UUID(), tabID: UUID(), windowID: 1, - workspacePath: nil + workspacePaths: .uniform(nil) ) return controller.parseExecCommandEndEvent(params: params)?.resultJSON } @@ -7950,7 +7952,7 @@ final class CodexNativeSessionController { static func appServerTurnSandboxPolicyPayload( mode: CodexAgentToolPreferences.SandboxMode, - workspacePath: String? + executionDirectory: String? ) -> [String: Any] { switch mode { case .readOnly: @@ -7962,10 +7964,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/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 c7ada1702..44f1cbeb9 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. @@ -995,6 +995,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/MCP/Agent/AgentMCPStartWorktreeCoordinator.swift b/Sources/RepoPrompt/Infrastructure/MCP/Agent/AgentMCPStartWorktreeCoordinator.swift index be8a6126f..a0505bcc2 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,22 +451,22 @@ 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, explicitLogicalRoot: explicitLogicalRoot, - visibleRoots: visibleRoots + visibleRoots: discoveryRoots ) 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 = GitRepoRootAuthorization.canonicalPath(primaryRoot) + guard let primaryIndex = visibleRoots.firstIndex(where: { + GitRepoRootAuthorization.canonicalPath($0.standardizedFullPath) == 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 8b26b3ddb..534f67397 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/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 new file mode 100644 index 000000000..c3c0f7e27 --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/Process/ChildProcessExitObserver.swift @@ -0,0 +1,223 @@ +import Darwin +import Dispatch +import Foundation + +/// One cancellation-independent owner for a direct child's destructive reap. +/// 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) + } + + private final class State: @unchecked Sendable { + private let lock = NSLock() + private var rootSignalingClosed = false + private var outcome: Outcome? + private var waiters: [UUID: CheckedContinuation] = [:] + + func closeRootSignalingBeforeReap() { + lock.lock() + rootSignalingClosed = true + lock.unlock() + } + + func reopenRootSignalingAfterWaitFailure() -> Bool { + lock.lock() + defer { lock.unlock() } + guard outcome == nil else { return false } + rootSignalingClosed = false + return true + } + + func finish(with outcome: Outcome) { + lock.lock() + guard self.outcome == nil else { + lock.unlock() + return + } + self.outcome = outcome + // Keep publication idempotently closed to PID signaling even if a + // future result path reaches finish without the callback boundary. + rootSignalingClosed = 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 !rootSignalingClosed, outcome == nil else { return nil } + return operation() + } + + var isRootSignalingClosed: Bool { + lock.lock() + defer { lock.unlock() } + return rootSignalingClosed + } + + 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 + private static let outcomePublicationQueue = DispatchQueue( + label: "com.repoprompt.child-process-exit-observer.outcome-publication", + qos: .userInitiated, + 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 }, + 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 + + 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 + /// registry closes this lock-protected window before its destructive wait, + /// before diagnostic outcome publication can block or change executors. + func signalRootProcessFamilyIfUnreaped( + processGroupID: pid_t?, + signal: Int32, + logger: (String) -> Void = { _ in } + ) -> Bool? { + state.withRootSignalingWindow { + ProcessTermination.signalProcessGroupOrPID( + pid: pid, + processGroupID: processGroupID, + signal: signal, + logger: logger + ) + } + } + + /// 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, + consecutiveWaitFailures: Int = 0 + ) { + statusObserver( + pid, + { + state.closeRootSignalingBeforeReap() + afterClosingRootSignalingBeforeReap() + }, + { result in + if case .failure(.waitFailed) = result, + state.reopenRootSignalingAfterWaitFailure() + { + 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, + consecutiveWaitFailures: failureCount + ) + } + 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 06b755345..ca035c868 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) @@ -55,6 +55,202 @@ enum ProcessTerminationError: Error, 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 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 + ) + private var entries: [pid_t: Entry] = [:] + + func observe( + pid: pid_t, + beforeReap: @escaping @Sendable () -> Void, + 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 fallbackProbeTimer = DispatchSource.makeTimerSource(queue: queue) + let entry = Entry( + token: token, + source: source, + fallbackProbeTimer: fallbackProbeTimer, + beforeReap: beforeReap, + 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() + + 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. + 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 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) + 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() + entry.fallbackProbeTimer.cancel() + } +} + enum ProcessTermination { private struct TerminationTiming { let cooperativeWaitTimeout: TimeInterval @@ -73,12 +269,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 +521,47 @@ 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 + /// `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, + beforeReap: beforeReap, + 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 = {} + beforeReap: @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, beforeReap: beforeReap) { result in + continuation.resume(with: result) } } } @@ -416,6 +610,57 @@ 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 bounded SIGKILL cleanup; sole-reaper observation remains active") + if observer.isRootSignalingClosed { + await terminateProcessGroupAfterRootReap( + processGroupID: processGroupID, + sigtermGrace: termGrace, + sigkillGrace: killGrace, + logger: logger + ) + } + return + } + } + + await terminateProcessGroupAfterRootReap( + processGroupID: processGroupID, + sigtermGrace: termGrace, + sigkillGrace: killGrace, + logger: logger + ) + } + static func waitForTermination( pid: pid_t, processGroupID: pid_t?, diff --git a/Sources/RepoPrompt/Infrastructure/VCS/GitService.swift b/Sources/RepoPrompt/Infrastructure/VCS/GitService.swift index 23436ae1a..6389adc5b 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 @@ -7216,7 +7216,7 @@ actor GitService { reapOutcome = try await .success( ProcessTermination.reapChildStatus( pid: spawned.pid, - onReaped: { target.markTerminated() } + beforeReap: { target.markTerminated() } ) ) reapRequiresGroupCleanup = false @@ -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 @@ -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/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/AI/ProcessTerminationExitStatusTests.swift b/Tests/RepoPromptTests/AI/ProcessTerminationExitStatusTests.swift index f289422b1..de02a471d 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 @@ -7,6 +8,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. @@ -48,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) @@ -88,6 +99,393 @@ 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 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", + 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 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) + 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 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 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", @@ -120,4 +518,311 @@ final class ProcessTerminationExitStatusTests: XCTestCase { XCTAssertEqual(signalOutcome.status, .uncaughtSignal(signal: SIGKILL)) XCTAssertEqual(signalOutcome.status.normalizedExitCode, 128 + SIGKILL) } + + 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), + 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 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() + 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 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 { + 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 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 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 + + 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 + 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 { + 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..63ff27bbc --- /dev/null +++ b/Tests/RepoPromptTests/AgentMode/Codex/CodexAppServerClientProcessExitTests.swift @@ -0,0 +1,911 @@ +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 outcomePublicationGate = ChildExitOutcomePublicationGate() + 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, + 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 { + do { + try await client.startIfNeeded() + await startupCompletion.markComplete() + } catch { + await startupCompletion.markComplete() + throw error + } + } + 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 { + return false + } + return true + } + try await waitUntil("expected PID clear", timeout: deadline.remaining) { + 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 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) + 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( + 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) + addTeardownBlock { + await client.stop() + } + 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("decode recovery teardown", 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 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") + 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() } + ) + let restartTaskCleanup = ThrowingTaskCleanup() + addTeardownBlock { + await spawnPreparation.release() + await client.stop() + await restartTaskCleanup.finish() + } + try await client.startIfNeeded() + try await waitUntil("initial spawn count", timeout: 2) { + (try? String(contentsOf: spawnCountURL, encoding: .utf8)) == "1" + } + await client.stop() + + let restart = Task { + try await client.startIfNeeded() + } + await restartTaskCleanup.track(restart) + try await waitUntil("restart preparation gate", 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 = {}, + 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( + 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 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 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, + 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 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 { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await condition() { return } + try await Task.sleep(nanoseconds: 10_000_000) + } + 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 + } +} + +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 + } +} 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/CodexMCPBootstrapReadinessTests.swift b/Tests/RepoPromptTests/AgentMode/Codex/CodexMCPBootstrapReadinessTests.swift index 1d026a258..626df7ecd 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, 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 ef1289e84..130289e31 100644 --- a/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift +++ b/Tests/RepoPromptTests/AgentMode/Codex/CodexNativeSessionControllerGoalConfigTests.swift @@ -93,7 +93,7 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { commandName: executableURL.path, additionalPathHints: [], requestTimeout: 5, - workingDirectory: directory.path + processLaunchDirectory: directory.path ) ) @@ -119,7 +119,7 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { commandName: executableURL.path, additionalPathHints: [], requestTimeout: 5, - workingDirectory: directory.path, + processLaunchDirectory: directory.path, processModelReasoningSummary: .auto ) ) @@ -132,6 +132,242 @@ 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 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( + 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") + XCTAssertNil(resumeParams["path"]) + + 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, @@ -419,7 +655,7 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { commandName: executableURL.path, additionalPathHints: [], requestTimeout: 5, - workingDirectory: directory.path + processLaunchDirectory: directory.path ) ) @@ -428,7 +664,7 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { runID: UUID(), tabID: UUID(), windowID: 0, - workspacePath: directory.path, + workspacePaths: .uniform(directory.path), options: options, clientShutdownBehavior: .stopOnShutdown ) @@ -445,6 +681,7 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { let script = """ #!/usr/bin/env python3 import json + import os import sys record_path = \(String(reflecting: recordURL.path)) @@ -452,7 +689,7 @@ final class CodexNativeSessionControllerGoalConfigTests: XCTestCase { goal_status = \(goalStatus.map { String(reflecting: $0) } ?? "None") 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) @@ -475,6 +712,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"}}) elif method == "thread/goal/get" and goal_status is not None: respond(request["id"], {"goal": { "threadId": "fresh-thread", @@ -493,6 +732,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]) 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..d607523e0 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 { @@ -481,6 +481,379 @@ 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.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) + + 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) + 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) + + 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 { + 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") @@ -2230,6 +2603,226 @@ 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 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) + 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) + } + + 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 + ) + let loadedNestedPrimary = try await WorkspaceRootLoadTestSupport.loadRootMatchingCurrentFileSystemSettings( + in: nestedWindow, + path: nestedPrimary.path + ) + let loadedRoots = await nestedStore.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("@branch:\(nestedBranch)"), + "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, nestedWorktreePrimary.path) + } + + 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) @@ -4111,80 +4704,48 @@ 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 + private let shutdownGate: AgentRunWorktreeStartAsyncGate? + + init(shutdownGate: AgentRunWorktreeStartAsyncGate? = nil) { + self.shutdownGate = shutdownGate + 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 + if let shutdownGate { + await shutdownGate.markStartedAndWaitForRelease() + } + eventsContinuation?.finish() + eventsContinuation = nil } - - func cancelCurrentTurn() async {} - func shutdown() async {} - func respondToServerRequest(id: CodexAppServerRequestID, result: [String: Any]) async {} } private actor AgentRunWorktreeStartAsyncGate { diff --git a/Tests/RepoPromptTests/MCP/ToolCatalogSnapshotTests.swift b/Tests/RepoPromptTests/MCP/ToolCatalogSnapshotTests.swift index 9a84efd57..fd70c218f 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=09d41fa6b1f6356ac8b7acee84d187ff47ad5bdf772c86a15aed81b3f0d70f02|schema=83f34927eacac4dc6352db72eae312ac3a5477b2f70c9031f09a2101dc8f2e97", "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",