From 14bec13a7946c19249cc647a74f5fc8dcd7f5a2c Mon Sep 17 00:00:00 2001 From: morluto Date: Mon, 6 Jul 2026 08:26:07 +0800 Subject: [PATCH 1/3] fix(workspaces): make tab slice rebases planned --- .../ViewModels/WorkspaceFilesViewModel.swift | 278 ++++++++++++++---- .../WorkspaceManagerViewModel.swift | 120 ++++++-- 2 files changed, 304 insertions(+), 94 deletions(-) diff --git a/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift b/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift index 1667f4c4c..445e2afa1 100644 --- a/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift +++ b/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift @@ -428,6 +428,18 @@ private struct SlicePartitionRebaseCommit { } } +private struct TabSliceRebasePlanInput { + let identity: WorkspaceSelectionIdentity + let fullPath: String + let expectedRanges: [LineRange] +} + +private struct HiddenSessionTabSliceRebasePlan { + let target: HiddenSessionSliceRebaseTarget + let expectedLogicalRanges: [LineRange] + let plan: WorkspaceManagerViewModel.WorkspaceTabSliceRebasePlan +} + private struct ReplayRootPassAccumulator { let rootKey: String var processedDigests: [WorkspaceFilesViewModel.FileSystemDeltaDigest] = [] @@ -1144,6 +1156,7 @@ class WorkspaceFilesViewModel: ObservableObject { private var appliedIndexProjectionIndexRebuildVisitedFileCount = 0 private var appliedIndexProjectionWillHandleHandler: (@Sendable (UUID, UInt64) async -> Void)? private var hiddenSessionSliceRebaseWillCommitHandler: (@Sendable (String) async -> Void)? + private var hiddenSessionSliceRebaseWillApplyTabPlansHandler: (@Sendable (String) async -> Void)? private var appliedIndexProjectionStateObserver: ((AppliedIndexProjectionDiagnosticsSnapshot) -> Void)? func setAppliedIndexProjectionWillHandleHandlerForTesting( @@ -1158,6 +1171,12 @@ class WorkspaceFilesViewModel: ObservableObject { hiddenSessionSliceRebaseWillCommitHandler = handler } + func setHiddenSessionSliceRebaseWillApplyTabPlansHandlerForTesting( + _ handler: (@Sendable (String) async -> Void)? + ) { + hiddenSessionSliceRebaseWillApplyTabPlansHandler = handler + } + func setAppliedIndexProjectionStateObserverForTesting( _ observer: ((AppliedIndexProjectionDiagnosticsSnapshot) -> Void)? ) { @@ -1608,6 +1627,22 @@ class WorkspaceFilesViewModel: ObservableObject { return true } + private nonisolated static func tabSliceRebaseDedupeKey( + identity: WorkspaceSelectionIdentity, + fullPath: String + ) -> String? { + guard let standardizedFullPath = StoredSelectionPathNormalization.standardizedPath(fullPath) else { + return nil + } + return "\(identity.workspaceID.uuidString):\(identity.tabID.uuidString):\(standardizedFullPath)" + } + + private nonisolated static func tabSliceRebaseDedupeKey( + for plan: WorkspaceManagerViewModel.WorkspaceTabSliceRebasePlan + ) -> String { + "\(plan.identity.workspaceID.uuidString):\(plan.identity.tabID.uuidString):\(plan.fullPath)" + } + @MainActor private func hiddenSessionSliceRebaseTargets( physicalRootPath: String, @@ -1748,6 +1783,8 @@ class WorkspaceFilesViewModel: ObservableObject { taskID: taskID, registrationGeneration: registrationGeneration ) else { return } + var tabPlans: [HiddenSessionTabSliceRebasePlan] = [] + var plannedTargetKeys = Set() for commit in partitionCommits { guard await isHiddenSessionSliceRebaseCurrent( request, @@ -1777,52 +1814,69 @@ class WorkspaceFilesViewModel: ObservableObject { ) != nil else { return } + if let target = commit.hiddenSessionTarget, + let expectedLogicalRanges = commit.expectedLogicalRanges, + let plan = WorkspaceManagerViewModel.WorkspaceTabSliceRebasePlan( + identity: target.identity, + fullPath: target.logicalFullPath, + expectedRanges: expectedLogicalRanges, + rebasedRanges: commit.ranges + ) + { + tabPlans.append(HiddenSessionTabSliceRebasePlan( + target: target, + expectedLogicalRanges: expectedLogicalRanges, + plan: plan + )) + plannedTargetKeys.insert(Self.tabSliceRebaseDedupeKey(for: plan)) + } } catch { return } } - var stillCurrentTargets: [HiddenSessionSliceRebaseTarget] = [] - for target in currentTargets where await isHiddenSessionSliceRebaseTargetCurrent(target) { - stillCurrentTargets.append(target) - } - guard !stillCurrentTargets.isEmpty else { return } - let targets = stillCurrentTargets.map { (identity: $0.identity, fullPath: $0.logicalFullPath) } - await workspaceManager?.rebaseSlicesForFilesAcrossTabs( - targets: targets, - asyncTransform: { identity, logicalFullPath, currentRanges in - if let commit = partitionCommits.first(where: { commit in - guard let target = commit.hiddenSessionTarget else { return false } - return target.identity == identity && target.logicalFullPath == logicalFullPath - }), let expectedLogicalRanges = commit.expectedLogicalRanges { - let normalizedCurrent = SliceRangeMath.normalize(currentRanges) - let normalizedCommitted = SliceRangeMath.normalize(commit.ranges) - if normalizedCurrent == normalizedCommitted { - return normalizedCurrent - } - if normalizedCurrent == SliceRangeMath.normalize(expectedLogicalRanges) { - return normalizedCommitted - } - } - - return await Task.detached(priority: .utility) { - let engineStartedMS = ProcessInfo.processInfo.systemUptime * 1000 - let result = SliceRebaseEngine.rebase( - oldText: sourceSnapshot.text, - newText: loadedSnapshot.text, - oldRanges: currentRanges, - anchors: nil - ) - #if DEBUG - MCPApplyEditsRebaseProbeRecorder.recordEngineCall( - fullPath: logicalFullPath, - durationMS: ProcessInfo.processInfo.systemUptime * 1000 - engineStartedMS, - scope: "hidden-tab" - ) - #endif - return result.isStale ? currentRanges : result.rebased - }.value + for target in currentTargets { + guard let targetKey = Self.tabSliceRebaseDedupeKey( + identity: target.identity, + fullPath: target.logicalFullPath + ), !plannedTargetKeys.contains(targetKey), + await isHiddenSessionSliceRebaseTargetCurrent(target), + let expectedLogicalRanges = await hiddenSessionSliceRangesIfTargetCurrent(target) + else { continue } + let input = TabSliceRebasePlanInput( + identity: target.identity, + fullPath: target.logicalFullPath, + expectedRanges: expectedLogicalRanges + ) + if let plan = await Self.computeTabSliceRebasePlan( + input: input, + oldText: sourceSnapshot.text, + newText: loadedSnapshot.text, + debugFullPath: target.logicalFullPath, + debugScope: "hidden-tab" + ) { + tabPlans.append(HiddenSessionTabSliceRebasePlan( + target: target, + expectedLogicalRanges: expectedLogicalRanges, + plan: plan + )) + plannedTargetKeys.insert(Self.tabSliceRebaseDedupeKey(for: plan)) } - ) + } + #if DEBUG + if let hiddenSessionSliceRebaseWillApplyTabPlansHandler { + await hiddenSessionSliceRebaseWillApplyTabPlansHandler(fullPath) + } + #endif + var applicablePlans: [WorkspaceManagerViewModel.WorkspaceTabSliceRebasePlan] = [] + for tabPlan in tabPlans { + guard await isHiddenSessionSliceRebaseTargetCurrent(tabPlan.target), + await hiddenSessionSliceRangesIfTargetCurrent(tabPlan.target) == tabPlan.expectedLogicalRanges + else { continue } + applicablePlans.append(tabPlan.plan) + } + if !applicablePlans.isEmpty { + await workspaceManager?.applyPlannedSliceRebasesAcrossTabs(applicablePlans) + } guard await isHiddenSessionSliceRebaseCurrent( request, taskID: taskID, @@ -12506,6 +12560,111 @@ extension WorkspaceFilesViewModel { return false } + @MainActor + private func captureTabSliceRebasePlanInputs( + workspaceID: UUID, + fullPath: String + ) -> [TabSliceRebasePlanInput] { + guard let standardizedFullPath = StoredSelectionPathNormalization.standardizedPath(fullPath), + let tabs = workspaceManager?.activeWorkspace?.composeTabs + else { return [] } + + var inputs: [TabSliceRebasePlanInput] = [] + var seen = Set() + for tab in tabs where seen.insert(tab.id).inserted { + let slices = StoredSelectionPathNormalization.standardizedSlices(tab.selection.slices) + guard let ranges = slices[standardizedFullPath] else { continue } + let normalizedRanges = SliceRangeMath.normalize(ranges) + guard !normalizedRanges.isEmpty else { continue } + inputs.append(TabSliceRebasePlanInput( + identity: WorkspaceSelectionIdentity(workspaceID: workspaceID, tabID: tab.id), + fullPath: standardizedFullPath, + expectedRanges: normalizedRanges + )) + } + return inputs + } + + private nonisolated static func matchingTabPartitionCommit( + for input: TabSliceRebasePlanInput, + in commits: [SlicePartitionRebaseCommit] + ) -> SlicePartitionRebaseCommit? { + commits.first { commit in + guard commit.scope.workspaceID == input.identity.workspaceID, + commit.scope.tabID == input.identity.tabID + else { return false } + return SliceRangeMath.normalize(commit.expectedStoredSlices.ranges) == input.expectedRanges + } + } + + private nonisolated static func computeTabSliceRebasePlan( + input: TabSliceRebasePlanInput, + oldText: String, + newText: String, + debugFullPath: String, + debugScope: String + ) async -> WorkspaceManagerViewModel.WorkspaceTabSliceRebasePlan? { + guard oldText != newText else { return nil } + let computed = await Task.detached(priority: .utility) { + let engineStartedMS = ProcessInfo.processInfo.systemUptime * 1000 + let result = SliceRebaseEngine.rebase( + oldText: oldText, + newText: newText, + oldRanges: input.expectedRanges, + anchors: nil + ) + #if DEBUG + MCPApplyEditsRebaseProbeRecorder.recordEngineCall( + fullPath: debugFullPath, + durationMS: ProcessInfo.processInfo.systemUptime * 1000 - engineStartedMS, + scope: debugScope + ) + #endif + return result.isStale ? nil : result.rebased + }.value + guard let computed else { return nil } + return WorkspaceManagerViewModel.WorkspaceTabSliceRebasePlan( + identity: input.identity, + fullPath: input.fullPath, + expectedRanges: input.expectedRanges, + rebasedRanges: computed + ) + } + + private nonisolated static func computeTabSliceRebasePlans( + inputs: [TabSliceRebasePlanInput], + partitionCommits: [SlicePartitionRebaseCommit], + oldText: String, + newText: String, + fullPath: String + ) async -> [WorkspaceManagerViewModel.WorkspaceTabSliceRebasePlan] { + var plans: [WorkspaceManagerViewModel.WorkspaceTabSliceRebasePlan] = [] + for input in inputs { + if let commit = matchingTabPartitionCommit(for: input, in: partitionCommits), + let plan = WorkspaceManagerViewModel.WorkspaceTabSliceRebasePlan( + identity: input.identity, + fullPath: input.fullPath, + expectedRanges: input.expectedRanges, + rebasedRanges: commit.ranges + ) + { + plans.append(plan) + continue + } + + if let plan = await computeTabSliceRebasePlan( + input: input, + oldText: oldText, + newText: newText, + debugFullPath: fullPath, + debugScope: "tab" + ) { + plans.append(plan) + } + } + return plans + } + @MainActor private func rebaseSlicesForModifiedFile( _ file: FileViewModel, @@ -12555,6 +12714,7 @@ extension WorkspaceFilesViewModel { ) else { return } + let tabPlanInputs = captureTabSliceRebasePlanInputs(workspaceID: workspaceID, fullPath: fullPath) var partitionCommits: [SlicePartitionRebaseCommit] = [] var encounteredStalePartition = false @@ -12608,6 +12768,14 @@ extension WorkspaceFilesViewModel { )) } + let tabPlans = await Self.computeTabSliceRebasePlans( + inputs: tabPlanInputs, + partitionCommits: partitionCommits, + oldText: sourceSnapshot?.text ?? loadedSnapshot.text, + newText: loadedSnapshot.text, + fullPath: fullPath + ) + await beginSliceRebaseCommit( workspaceID: workspaceID, rootID: workspaceRoot.id, @@ -12619,6 +12787,7 @@ extension WorkspaceFilesViewModel { relativePath: relKey, activeScope: activeScope, partitionCommits: partitionCommits, + tabPlans: tabPlans, sourceSnapshot: sourceSnapshot, loadedSnapshot: loadedSnapshot, encounteredStalePartition: encounteredStalePartition @@ -12712,6 +12881,7 @@ extension WorkspaceFilesViewModel { relativePath: String, activeScope: PartitionScope?, partitionCommits: [SlicePartitionRebaseCommit], + tabPlans: [WorkspaceManagerViewModel.WorkspaceTabSliceRebasePlan], sourceSnapshot: SliceRebaseSourceSnapshot?, loadedSnapshot: SliceRebaseSourceSnapshot, encounteredStalePartition: Bool @@ -12737,6 +12907,7 @@ extension WorkspaceFilesViewModel { relativePath: relativePath, activeScope: activeScope, partitionCommits: partitionCommits, + tabPlans: tabPlans, sourceSnapshot: sourceSnapshot, loadedSnapshot: loadedSnapshot, encounteredStalePartition: encounteredStalePartition @@ -12758,6 +12929,7 @@ extension WorkspaceFilesViewModel { relativePath: String, activeScope: PartitionScope?, partitionCommits: [SlicePartitionRebaseCommit], + tabPlans: [WorkspaceManagerViewModel.WorkspaceTabSliceRebasePlan], sourceSnapshot: SliceRebaseSourceSnapshot?, loadedSnapshot: SliceRebaseSourceSnapshot, encounteredStalePartition: Bool @@ -12837,29 +13009,7 @@ extension WorkspaceFilesViewModel { ) else { return } - let tabOldText = sourceSnapshot?.text ?? loadedSnapshot.text - await workspaceManager?.rebaseSlicesForFileAcrossTabs( - fullPath: fullPath, - asyncTransform: { _, current in - await Task.detached(priority: .utility) { - let engineStartedMS = ProcessInfo.processInfo.systemUptime * 1000 - let result = SliceRebaseEngine.rebase( - oldText: tabOldText, - newText: loadedSnapshot.text, - oldRanges: current, - anchors: nil - ) - #if DEBUG - MCPApplyEditsRebaseProbeRecorder.recordEngineCall( - fullPath: fullPath, - durationMS: ProcessInfo.processInfo.systemUptime * 1000 - engineStartedMS, - scope: "tab" - ) - #endif - return result.isStale ? current : result.rebased - }.value - } - ) + await workspaceManager?.applyPlannedSliceRebasesAcrossTabs(tabPlans) guard persistenceSucceeded, !encounteredStalePartition, diff --git a/Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceManagerViewModel.swift b/Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceManagerViewModel.swift index 1b6b07400..bcaea6c85 100644 --- a/Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceManagerViewModel.swift +++ b/Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceManagerViewModel.swift @@ -4366,6 +4366,28 @@ class WorkspaceManagerViewModel: ObservableObject { return true } + struct WorkspaceTabSliceRebasePlan: Equatable { + let identity: WorkspaceSelectionIdentity + let fullPath: String + let expectedRanges: [LineRange] + let rebasedRanges: [LineRange] + + init?( + identity: WorkspaceSelectionIdentity, + fullPath: String, + expectedRanges: [LineRange], + rebasedRanges: [LineRange] + ) { + guard let standardizedFullPath = StoredSelectionPathNormalization.standardizedPath(fullPath) else { + return nil + } + self.identity = identity + self.fullPath = standardizedFullPath + self.expectedRanges = SliceRangeMath.normalize(expectedRanges) + self.rebasedRanges = SliceRangeMath.normalize(rebasedRanges) + } + } + nonisolated static func rebasedStoredSelectionSlices( _ selection: StoredSelection, for fullPath: String, @@ -4381,6 +4403,43 @@ class WorkspaceManagerViewModel: ObservableObject { } let nextRanges = SliceRangeMath.normalize(transform(existingRanges)) + return storedSelection( + selection, + replacingSlices: normalizedSlices, + for: standardizedFullPath, + with: nextRanges + ) + } + + nonisolated static func rebasedStoredSelectionSlices( + _ selection: StoredSelection, + applying plan: WorkspaceTabSliceRebasePlan + ) -> StoredSelection? { + let normalizedSlices = StoredSelectionPathNormalization.standardizedSlices(selection.slices) + guard let currentRanges = normalizedSlices[plan.fullPath] else { + return nil + } + let normalizedCurrent = SliceRangeMath.normalize(currentRanges) + if normalizedCurrent == plan.rebasedRanges { + return nil + } + guard normalizedCurrent == plan.expectedRanges else { + return nil + } + return storedSelection( + selection, + replacingSlices: normalizedSlices, + for: plan.fullPath, + with: plan.rebasedRanges + ) + } + + private nonisolated static func storedSelection( + _ selection: StoredSelection, + replacingSlices normalizedSlices: [String: [LineRange]], + for standardizedFullPath: String, + with nextRanges: [LineRange] + ) -> StoredSelection? { var nextSlices = normalizedSlices if nextRanges.isEmpty { nextSlices.removeValue(forKey: standardizedFullPath) @@ -4484,50 +4543,51 @@ class WorkspaceManagerViewModel: ObservableObject { asyncTransform: @Sendable (WorkspaceSelectionIdentity, String, [LineRange]) async -> [LineRange] ) async { var seen = Set() + var plans: [WorkspaceTabSliceRebasePlan] = [] for target in targets { guard let standardizedFullPath = StoredSelectionPathNormalization.standardizedPath(target.fullPath) else { continue } let dedupeKey = "\(target.identity.workspaceID.uuidString):\(target.identity.tabID.uuidString):\(standardizedFullPath)" guard seen.insert(dedupeKey).inserted else { continue } + guard let currentTab = composeTab(for: target.identity), + !currentTab.selection.slices.isEmpty + else { continue } + let currentSlices = StoredSelectionPathNormalization.standardizedSlices(currentTab.selection.slices) + guard let currentRanges = currentSlices[standardizedFullPath] else { continue } + let nextRanges = await asyncTransform(target.identity, standardizedFullPath, currentRanges) + if let plan = WorkspaceTabSliceRebasePlan( + identity: target.identity, + fullPath: standardizedFullPath, + expectedRanges: currentRanges, + rebasedRanges: nextRanges + ) { + plans.append(plan) + } + } + await applyPlannedSliceRebasesAcrossTabs(plans) + } + + @MainActor + func applyPlannedSliceRebasesAcrossTabs(_ plans: [WorkspaceTabSliceRebasePlan]) async { + var seen = Set() + for plan in plans { + let dedupeKey = "\(plan.identity.workspaceID.uuidString):\(plan.identity.tabID.uuidString):\(plan.fullPath)" + guard seen.insert(dedupeKey).inserted else { continue } #if DEBUG - MCPApplyEditsRebaseProbeRecorder.recordTabInspection(fullPath: standardizedFullPath) + MCPApplyEditsRebaseProbeRecorder.recordTabInspection(fullPath: plan.fullPath) #endif for _ in 0 ..< 3 { - guard let currentTab = composeTab(for: target.identity), - !currentTab.selection.slices.isEmpty + guard var latestTab = composeTab(for: plan.identity), + let nextSelection = Self.rebasedStoredSelectionSlices(latestTab.selection, applying: plan) else { break } - let currentSlices = StoredSelectionPathNormalization.standardizedSlices(currentTab.selection.slices) - guard let currentRanges = currentSlices[standardizedFullPath] else { break } - - let nextRanges = await SliceRangeMath.normalize( - asyncTransform(target.identity, standardizedFullPath, currentRanges) - ) - guard var latestTab = composeTab(for: target.identity) else { break } - let latestSlices = StoredSelectionPathNormalization.standardizedSlices(latestTab.selection.slices) - guard let latestRanges = latestSlices[standardizedFullPath] else { break } - guard latestRanges == currentRanges else { continue } - - var nextSlices = latestSlices - if nextRanges.isEmpty { - nextSlices.removeValue(forKey: standardizedFullPath) - } else { - nextSlices[standardizedFullPath] = nextRanges - } - guard nextSlices != latestTab.selection.slices else { break } let expectedSelection = latestTab.selection - let nextSelection = StoredSelection( - selectedPaths: expectedSelection.selectedPaths, - manualCodemapPaths: expectedSelection.manualCodemapPaths, - slices: nextSlices, - codemapAutoEnabled: expectedSelection.codemapAutoEnabled - ) let persistedSelection: StoredSelection if let selectionCoordinator { persistedSelection = await selectionCoordinator.persistSelection( nextSelection, - for: target.identity, + for: plan.identity, source: .mcpTabContext, mirrorToUIIfActive: true, expectedCurrentSelection: expectedSelection @@ -4537,12 +4597,12 @@ class WorkspaceManagerViewModel: ObservableObject { latestTab.lastModified = Date() persistedSelection = updateComposeTabStoredOnly( latestTab, - inWorkspaceID: target.identity.workspaceID + inWorkspaceID: plan.identity.workspaceID ) ? nextSelection : expectedSelection } guard persistedSelection == nextSelection else { continue } #if DEBUG - MCPApplyEditsRebaseProbeRecorder.recordTabWrite(fullPath: standardizedFullPath) + MCPApplyEditsRebaseProbeRecorder.recordTabWrite(fullPath: plan.fullPath) #endif break } From c57c10a30d2a478bb23ed044edb2e7a01b6992e8 Mon Sep 17 00:00:00 2001 From: morluto Date: Mon, 6 Jul 2026 08:27:55 +0800 Subject: [PATCH 2/3] test(workspaces): cover planned slice rebase races --- .../Fixtures/test-suite-contract-ledger.tsv | 3 +- ...tAgentModeMCPReadFileConnectionTests.swift | 61 ++++++++++++++++++- ...ectionSlicePersistenceAndRebaseTests.swift | 38 ++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index 5667913e1..e43f094c8 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -2023,7 +2023,7 @@ root/RepoPromptTests.PathMatchingRecoveryTests/testMovePathResolverRejectsAmbigu root/RepoPromptTests.PathMatchingRecoveryTests/testUnicodeAndCaseInsensitiveLookupPreserveStoredRelativePath root Tests/RepoPromptTests/WorkspaceContext/PathMatching/PathMatchingRecoveryTests.swift RepoPromptTests.PathMatchingRecoveryTests testUnicodeAndCaseInsensitiveLookupPreserveStoredRelativePath WorkspaceContext unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 21 root/RepoPromptTests.PathSearchIndexRecoveryTests/testSearchMatchesFilenameSubpathTokensAndPublishesDeterministicRankMetadata root Tests/RepoPromptTests/WorkspaceContext/Search/PathSearchIndexRecoveryTests.swift RepoPromptTests.PathSearchIndexRecoveryTests testSearchMatchesFilenameSubpathTokensAndPublishesDeterministicRankMetadata WorkspaceContext unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.005000 unreviewed retain_pending_review 0 initial census source line 5 root/RepoPromptTests.PersistentAgentModeMCPReadFileConnectionTests/testAgentOwnedExplicitSetPersistsForIndependentCanonicalLookup root Tests/RepoPromptTests/MCP/Control/PersistentAgentModeMCPReadFileConnectionTests.swift RepoPromptTests.PersistentAgentModeMCPReadFileConnectionTests testAgentOwnedExplicitSetPersistsForIndependentCanonicalLookup MCP unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.541500 unreviewed retain_pending_review 0 initial census source line 28 -root/RepoPromptTests.PersistentAgentModeMCPReadFileConnectionTests/testAgentOwnedHiddenWorktreeWatcherRebases6500LineReadSlicesBeforePostEditReads root Tests/RepoPromptTests/MCP/Control/PersistentAgentModeMCPReadFileConnectionTests.swift RepoPromptTests.PersistentAgentModeMCPReadFileConnectionTests testAgentOwnedHiddenWorktreeWatcherRebases6500LineReadSlicesBeforePostEditReads MCP mcp.hidden_session_worktree.read_slice_watcher_rebase persistent_agent,read_file,6500_lines,watcher_rebase,physical_logical_mapping,full_selection_dominance,no_hidden_ui_tree,stale_deferred_commit,partition_cas deterministic_integration root_swiftpm routine 5 PersistentAgentModeMCPConnectionFixture,SessionWorktreeOwnershipFixture,WorkspaceSelectionSliceFixture Three canonical read_file slices rebase exactly after atomic replacement; a gated successor cannot resurrect a concurrently removed logical target or physical partition entry; the hidden root never projects into UI and later reads cannot downgrade a full selection. critical 1.386500 socketpair,temporary_worktree,actor_store,watcher_publisher,async_tasks test_fixture+explicit_root_unload retain 0 hidden session-root watcher rebase, stale deferred partition/target fencing, and full-file-wins integration +root/RepoPromptTests.PersistentAgentModeMCPReadFileConnectionTests/testAgentOwnedHiddenWorktreeWatcherRebases6500LineReadSlicesBeforePostEditReads root Tests/RepoPromptTests/MCP/Control/PersistentAgentModeMCPReadFileConnectionTests.swift RepoPromptTests.PersistentAgentModeMCPReadFileConnectionTests testAgentOwnedHiddenWorktreeWatcherRebases6500LineReadSlicesBeforePostEditReads MCP mcp.hidden_session_worktree.read_slice_watcher_rebase persistent_agent,read_file,6500_lines,watcher_rebase,physical_logical_mapping,full_selection_dominance,no_hidden_ui_tree,stale_deferred_commit,partition_cas,post_cas_tab_plan_stale_target deterministic_integration root_swiftpm routine 6 PersistentAgentModeMCPConnectionFixture,SessionWorktreeOwnershipFixture,WorkspaceSelectionSliceFixture Three canonical read_file slices rebase exactly after atomic replacement; a post-CAS stale hidden target cannot apply an already-built tab plan; a gated successor cannot resurrect a concurrently removed logical target or physical partition entry; the hidden root never projects into UI and later reads cannot downgrade a full selection. critical 1.386500 socketpair,temporary_worktree,actor_store,watcher_publisher,async_tasks test_fixture+explicit_root_unload retain 1 expanded hidden session-root watcher rebase coverage for post-CAS stale tab-plan filtering, stale deferred partition/target fencing, and full-file-wins integration root/RepoPromptTests.PersistentAgentModeMCPReadFileConnectionTests/testAgentOwnedWorktreeContentSearchCarriesPhysicalCoverageAndPreservesFullSelections root Tests/RepoPromptTests/MCP/Control/PersistentAgentModeMCPReadFileConnectionTests.swift RepoPromptTests.PersistentAgentModeMCPReadFileConnectionTests testAgentOwnedWorktreeContentSearchCarriesPhysicalCoverageAndPreservesFullSelections MCP mcp.hidden_session_worktree.search_context_watcher_rebase persistent_agent,file_search,context_lines,physical_coverage_identity,no_path_leak,watcher_rebase,full_selection_dominance deterministic_integration root_swiftpm routine 5 PersistentAgentModeMCPConnectionFixture,SessionWorktreeOwnershipFixture,WorkspaceSelectionSliceFixture Watcher-created worktree content is searchable without physical-path disclosure; context_lines=2 selects exact 2-6/13-17/25-29 logical slices, atomic replacement rebases them to 6-10/20-24/30-34, unrelated and matching full selections remain full. critical 0.821500 socketpair,temporary_worktree,actor_store,watcher_publisher,async_tasks test_fixture+explicit_session_owner_release retain 0 search physical coverage sidecar, hidden watcher rebase, and full-file-wins integration root/RepoPromptTests.PersistentAgentModeMCPReadFileConnectionTests/testCodexAgentModeLeaseRetainsOneMCPServerSessionAcrossSerialExactAbsoluteReadFileCalls root Tests/RepoPromptTests/MCP/Control/PersistentAgentModeMCPReadFileConnectionTests.swift RepoPromptTests.PersistentAgentModeMCPReadFileConnectionTests testCodexAgentModeLeaseRetainsOneMCPServerSessionAcrossSerialExactAbsoluteReadFileCalls MCP unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed unreviewed retain_pending_review 0 initial census source line 59 root/RepoPromptTests.PersistentAgentModeMCPReadFileConnectionTests/testManageSelectionGetStopsAtCanonicalHandoverWhileMirrorBlocked root Tests/RepoPromptTests/MCP/Control/PersistentAgentModeMCPReadFileConnectionTests.swift RepoPromptTests.PersistentAgentModeMCPReadFileConnectionTests testManageSelectionGetStopsAtCanonicalHandoverWhileMirrorBlocked MCP unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.333500 unreviewed retain_pending_review 0 initial census source line 49 @@ -2175,6 +2175,7 @@ root/RepoPromptTests.SelectionSlicePersistenceAndRebaseTests/testPartitionStoreC root/RepoPromptTests.SelectionSlicePersistenceAndRebaseTests/testPartitionStoreCASIsSerializedAcrossStoreInstances root Tests/RepoPromptTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift RepoPromptTests.SelectionSlicePersistenceAndRebaseTests testPartitionStoreCASIsSerializedAcrossStoreInstances WorkspaceContext workspace.slice_partition.cross_instance_cas_serialization compare_and_swap,cross_instance_serialization deterministic_concurrency root_swiftpm routine 1 PartitionStoreCASGate Two store instances serialize compare-and-swap so only the first stale-baseline writer succeeds and its partition remains persisted. critical 0.058000 temporary_directory,filesystem,dispatch_semaphore process_partition_file_lock test_case+defer retain 0 Cross-window store instances share the same partition-file CAS boundary. root/RepoPromptTests.SelectionSlicePersistenceAndRebaseTests/testPartitionStoreCancellationAfterAtomicWriteStillSucceedsAndNotifies root Tests/RepoPromptTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift RepoPromptTests.SelectionSlicePersistenceAndRebaseTests testPartitionStoreCancellationAfterAtomicWriteStillSucceedsAndNotifies WorkspaceContext workspace.slice_partition.post_commit_cancellation atomic_persistence,cancellation,notification deterministic_concurrency root_swiftpm routine 1 PartitionStorePersistGate Cancellation after the atomic write still returns the committed partition and posts the required change notification. critical 0.002500 temporary_directory,filesystem,notification_center,dispatch_semaphore notification_center test_case+defer retain 0 Post-commit cancellation must not turn durable success into reported failure. root/RepoPromptTests.SelectionSlicePersistenceAndRebaseTests/testPartitionStoreColdReloadPreservesSlicesAndIsolatesScopes root Tests/RepoPromptTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift RepoPromptTests.SelectionSlicePersistenceAndRebaseTests testPartitionStoreColdReloadPreservesSlicesAndIsolatesScopes WorkspaceContext unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.002500 unreviewed retain_pending_review 0 initial census source line 5 +root/RepoPromptTests.SelectionSlicePersistenceAndRebaseTests/testPlannedTabSliceRebaseIsIdempotentAndFailsClosed root Tests/RepoPromptTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift RepoPromptTests.SelectionSlicePersistenceAndRebaseTests testPlannedTabSliceRebaseIsIdempotentAndFailsClosed WorkspaceContext workspace.slice_rebase.planned_tab_contract expected_ranges,idempotent,fail_closed,tab_selection deterministic_regression root_swiftpm routine 3 Planned tab slice rebases apply only from expected ranges, no-op when already rebased, and leave independently changed ranges untouched. high 0.000500 method_local_values retain 0 Regression for coordinate-epoch ambiguous tab rebase retries. root/RepoPromptTests.SelectionSlicePersistenceAndRebaseTests/testSliceRebaseDropsOnlyWhenPostEditContentHasNoValidLine root Tests/RepoPromptTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift RepoPromptTests.SelectionSlicePersistenceAndRebaseTests testSliceRebaseDropsOnlyWhenPostEditContentHasNoValidLine WorkspaceContext workspace.slice_rebase.empty_document_drop slice_selection,post_edit_bounds deterministic_regression root_swiftpm routine 1 An edit producing a zero-line document drops the prior range and reports a real change rather than stale input. medium 0.000000 method_local_values retain 0 renamed from root/RepoPromptTests.SelectionSlicePersistenceAndRebaseTests/testSliceRebaseDropsRangesForEmptyAndUnmappableContent; unmappable nonempty inputs now preserve slices through stale behavior root/RepoPromptTests.SelectionSlicePersistenceAndRebaseTests/testSliceRebaseEqualCacheUsesSavedAnchorFallback root Tests/RepoPromptTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift RepoPromptTests.SelectionSlicePersistenceAndRebaseTests testSliceRebaseEqualCacheUsesSavedAnchorFallback WorkspaceContext workspace.slice_rebase.stale_snapshot_fallback anchors,stale_snapshot,no_empty_publication deterministic_regression root_swiftpm routine 5 Equal-cache anchors rebase correctly, while mismatched, unavailable, partial-anchor, or disjoint-anchor snapshots report stale and preserve existing ranges. high 0.001500 method_local_values retain 0 expanded in iteration 2 to cover mismatched and unavailable snapshot stale behavior root/RepoPromptTests.SelectionSlicePersistenceAndRebaseTests/testSliceRebaseLargeBeginningMiddleEndEditsAndRestoresStayCanonical root Tests/RepoPromptTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift RepoPromptTests.SelectionSlicePersistenceAndRebaseTests testSliceRebaseLargeBeginningMiddleEndEditsAndRestoresStayCanonical WorkspaceContext workspace.slice_rebase.large_bme_restore large_file,bounds,restore deterministic_regression root_swiftpm routine 6 generated_14050_line_fixture Beginning, middle, and end edit plus restore mappings remain nonempty, ordered, in bounds, and return to exact original ranges. high 0.086500 generated_large_file method_local_values retain 0 iteration 2 generated fixture; three edits and three restores diff --git a/Tests/RepoPromptTests/MCP/Control/PersistentAgentModeMCPReadFileConnectionTests.swift b/Tests/RepoPromptTests/MCP/Control/PersistentAgentModeMCPReadFileConnectionTests.swift index 87d4bf161..0b7e09bc5 100644 --- a/Tests/RepoPromptTests/MCP/Control/PersistentAgentModeMCPReadFileConnectionTests.swift +++ b/Tests/RepoPromptTests/MCP/Control/PersistentAgentModeMCPReadFileConnectionTests.swift @@ -532,6 +532,65 @@ final class PersistentAgentModeMCPReadFileConnectionTests: XCTestCase { XCTAssertNil(selection.slices[unrelatedLogicalPath]) XCTAssertEqual(selection.slices[targetLogicalPath], expectedRanges) + // Pause a successor after partition CAS but before planned tab application, then + // stale the hidden target without changing ranges. The post-CAS target recheck must + // prevent the already-built plan from applying to a no-longer-owned hidden session. + let staleTabPlanGate = PersistentAsyncGate() + fixture.window.workspaceFilesViewModel.setHiddenSessionSliceRebaseWillApplyTabPlansHandlerForTesting { path in + guard path == physicalURL.path else { return } + await staleTabPlanGate.markStartedAndWaitForRelease() + } + defer { + fixture.window.workspaceFilesViewModel.setHiddenSessionSliceRebaseWillApplyTabPlansHandlerForTesting(nil) + Task { await staleTabPlanGate.release() } + } + var postCASTabStaleLines = editedLines + postCASTabStaleLines.insert(contentsOf: (1 ... 10).map { "post-cas-insert-\($0)" }, at: 0) + let postCASReplacementURL = physicalURL.deletingLastPathComponent() + .appendingPathComponent(".SessionWorktree6500.swift.post-cas-\(UUID().uuidString)") + try (postCASTabStaleLines.joined(separator: "\n") + "\n").write( + to: postCASReplacementURL, + atomically: false, + encoding: .utf8 + ) + _ = try FileManager.default.replaceItemAt(physicalURL, withItemAt: postCASReplacementURL) + let postCASAccepted = try await fixture.window.workspaceFileContextStore.acceptWatcherPayloadForTesting( + rootID: fixture.installedWorktreeRootID, + events: [( + absolutePath: physicalURL.path, + flags: FSEventStreamEventFlags( + kFSEventStreamEventFlagItemRenamed + | kFSEventStreamEventFlagItemCreated + | kFSEventStreamEventFlagItemIsFile + ), + eventId: 8_900_000_000_000_000_002 + )] + ) + XCTAssertNotNil(postCASAccepted) + _ = await fixture.window.workspaceFileContextStore.awaitAppliedIngressForExplicitRequest( + userPath: physicalURL.path, + fallbackScope: .allLoaded + ) + try await requireGateStarted(staleTabPlanGate) + let workspaceIndex = try XCTUnwrap( + fixture.window.workspaceManager.workspaces.firstIndex { $0.id == fixture.workspaceID } + ) + let tabIndex = try XCTUnwrap( + fixture.window.workspaceManager.workspaces[workspaceIndex].composeTabs.firstIndex { $0.id == Fixture.tabID } + ) + fixture.window.workspaceManager.workspaces[workspaceIndex].composeTabs[tabIndex].activeAgentSessionID = nil + await staleTabPlanGate.release() + fixture.window.workspaceFilesViewModel.setHiddenSessionSliceRebaseWillApplyTabPlansHandlerForTesting(nil) + let postCASFence = await fixture.window.workspaceFilesViewModel.waitForPendingSliceRebasesAndCaptureFence( + affectingCandidatePaths: [physicalURL.path] + ) + XCTAssertTrue(fixture.window.workspaceFilesViewModel.isSliceRebaseFenceCurrent(postCASFence)) + selection = try XCTUnwrap( + fixture.window.workspaceManager.composeTab(with: Fixture.tabID)?.selection + ) + XCTAssertEqual(selection.slices[targetLogicalPath], expectedRanges) + fixture.window.workspaceManager.workspaces[workspaceIndex].composeTabs[tabIndex].activeAgentSessionID = Fixture.agentSessionID + // `manage_selection get` flushes pending active-UI state before replying. Hidden // worktree-only paths cannot materialize in the visible file tree, so the MCP-owned // canonical selection fence must advance with the watcher-driven rebase rather than @@ -575,7 +634,7 @@ final class PersistentAgentModeMCPReadFileConnectionTests: XCTestCase { | kFSEventStreamEventFlagItemCreated | kFSEventStreamEventFlagItemIsFile ), - eventId: 8_900_000_000_000_000_002 + eventId: 8_900_000_000_000_000_003 )] ) XCTAssertNotNil(staleAccepted) diff --git a/Tests/RepoPromptTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift b/Tests/RepoPromptTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift index b361b9f48..6f3801222 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift +++ b/Tests/RepoPromptTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift @@ -304,6 +304,44 @@ final class SelectionSlicePersistenceAndRebaseTests: XCTestCase { XCTAssertFalse(result.isStale) } + func testPlannedTabSliceRebaseIsIdempotentAndFailsClosed() throws { + let workspaceID = UUID() + let tabID = UUID() + let editedPath = "/tmp/Selected.swift" + let unrelatedPath = "/tmp/Unrelated.swift" + let expectedRanges = [LineRange(start: 10, end: 12, description: "edited")] + let rebasedRanges = [LineRange(start: 15, end: 17, description: "edited")] + let selection = StoredSelection( + selectedPaths: [editedPath, unrelatedPath], + slices: [ + editedPath: expectedRanges, + unrelatedPath: [LineRange(start: 30, end: 35, description: "unrelated")] + ], + codemapAutoEnabled: false + ) + let plan = try XCTUnwrap(WorkspaceManagerViewModel.WorkspaceTabSliceRebasePlan( + identity: WorkspaceSelectionIdentity(workspaceID: workspaceID, tabID: tabID), + fullPath: editedPath, + expectedRanges: expectedRanges, + rebasedRanges: rebasedRanges + )) + + let applied = try XCTUnwrap(WorkspaceManagerViewModel.rebasedStoredSelectionSlices(selection, applying: plan)) + XCTAssertEqual(applied.slices[editedPath], rebasedRanges) + XCTAssertEqual(applied.slices[unrelatedPath], selection.slices[unrelatedPath]) + XCTAssertNil(WorkspaceManagerViewModel.rebasedStoredSelectionSlices(applied, applying: plan)) + + let independentlyChanged = StoredSelection( + selectedPaths: selection.selectedPaths, + slices: [ + editedPath: [LineRange(start: 40, end: 42, description: "manual")], + unrelatedPath: [LineRange(start: 30, end: 35, description: "unrelated")] + ], + codemapAutoEnabled: false + ) + XCTAssertNil(WorkspaceManagerViewModel.rebasedStoredSelectionSlices(independentlyChanged, applying: plan)) + } + func testSliceRebaseTransformsOverlapMatrixWithStableAffinity() { struct Case { let name: String From d0f20c58f882204ea7ccced1f9a00d372d4bc331 Mon Sep 17 00:00:00 2001 From: morluto Date: Mon, 6 Jul 2026 09:12:02 +0800 Subject: [PATCH 3/3] test(agent-mode): stabilize oracle source capture fixture --- Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift b/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift index 06a10f4bd..2dab6c7dc 100644 --- a/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift +++ b/Tests/RepoPromptTests/MCP/AgentRunWorktreeStartTests.swift @@ -881,12 +881,17 @@ final class AgentRunWorktreeStartTests: AgentRunWorktreeStartGitSeedTestCase { // Wait for post-switch git-data root load to complete so any selection revision // published by the async git-data load settles before we diverge the UI selection. await window.workspaceManager.waitUntilPostSwitchGitDataLoadComplete() + + await window.promptManager.createBlankComposeTab(createAgentSession: false) + let divergentActiveTabID = try XCTUnwrap(window.workspaceManager.activeWorkspace?.activeComposeTabID) + XCTAssertNotEqual(divergentActiveTabID, sourceTabID) await window.selectionCoordinator.withApplyingSelectionMirror { await window.workspaceFilesViewModel.applyStoredSelection(StoredSelection()) } let divergentUISelection = window.workspaceFilesViewModel.snapshotSelection() XCTAssertTrue(divergentUISelection.selectedPaths.isEmpty) XCTAssertNotEqual(divergentUISelection, storedSelection) + _ = await window.selectionCoordinator.persistSelection( storedSelection, for: sourceIdentity, @@ -894,6 +899,7 @@ final class AgentRunWorktreeStartTests: AgentRunWorktreeStartGitSeedTestCase { mirrorToUIIfActive: false ) XCTAssertEqual(window.workspaceManager.composeTab(for: sourceIdentity)?.selection, storedSelection) + XCTAssertEqual(window.workspaceManager.activeWorkspace?.activeComposeTabID, divergentActiveTabID) let launchSnapshot = AgentRunOracleReviewLaunchSnapshot( route: .explicitTabContext,