From 792fe33c51e61769374a5211b98922a187ef2e9b Mon Sep 17 00:00:00 2001 From: Eric Provencher Date: Thu, 23 Jul 2026 19:09:57 -0400 Subject: [PATCH] feat(codemap): add per-root progress and controls --- .../Fixtures/test-suite-contract-ledger.tsv | 9 + .../AgentWorkspaceRootsSidebarStore.swift | 187 ++++++++- .../AgentMode/Views/AgentModeView.swift | 8 + .../AgentWorkspaceRootsSectionView.swift | 336 +++++++++++++++- .../ViewModels/WorkspaceFilesViewModel.swift | 50 +++ .../WorkspaceCodemapBindingEngineModels.swift | 6 + ...kspaceCodemapProjectionPreloadModels.swift | 39 ++ .../WorkspaceCodemapRootStatusModels.swift | 55 +++ .../WorkspaceCodemapBindingEngine.swift | 337 +++++++++++++++- .../WorkspaceFileContextStore.swift | 366 +++++++++++++++++- ...AgentWorkspaceRootsSidebarStoreTests.swift | 160 ++++++++ .../CodemapBindingEngineTestSupport.swift | 54 +++ .../Helpers/CodemapSeamTestSupport.swift | 5 +- .../CodemapBindingEngineProjectionTests.swift | 272 +++++++++++++ .../CodemapPreloadTests.swift | 278 +++++++++++++ 15 files changed, 2140 insertions(+), 22 deletions(-) create mode 100644 Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceCodemapRootStatusModels.swift diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index aa5792687..38f2f354c 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -502,6 +502,8 @@ root/RepoPromptTests.AgentTranscriptWindowedProjectionTests/testOpenTurnRemainsV root/RepoPromptTests.AgentTranscriptWindowedProjectionTests/testExpandedFlagRestoresExactProjectionRowsAndBlocks root Tests/RepoPromptTests/AgentMode/Transcript/AgentTranscriptWindowedProjectionTests.swift RepoPromptTests.AgentTranscriptWindowedProjectionTests testExpandedFlagRestoresExactProjectionRowsAndBlocks AgentMode agent_mode.transcript.tail_windowed_projection explicit_expand,projection_equivalence projection_contract root_swiftpm routine 1 Expanded history returns the exact unwindowed working projection rows and blocks. The explicit expand affordance could lose historical rows or alter row identity/order. 0.001000 direct_in_process test_case retain 0 PR #391 tail-window transcript rendering ledger reconciliation. root/RepoPromptTests.AgentTranscriptWindowedProjectionTests/testAppendingNewMessagesPreservesStableCollapsedRangeIDAndFullTail root Tests/RepoPromptTests/AgentMode/Transcript/AgentTranscriptWindowedProjectionTests.swift RepoPromptTests.AgentTranscriptWindowedProjectionTests testAppendingNewMessagesPreservesStableCollapsedRangeIDAndFullTail AgentMode agent_mode.transcript.tail_windowed_projection append_stability,tail_window,identity projection_contract root_swiftpm routine 1 Appending a new turn keeps the collapsed range identity stable and preserves the newest 40 full turns through the new tail. Streaming appends could churn collapsed range identity or stop keeping the recent tail fully visible. 0.001000 direct_in_process test_case retain 0 PR #391 tail-window transcript rendering ledger reconciliation. root/RepoPromptTests.AgentTranscriptWindowedProjectionTests/testHiddenAnchorRemapsToCollapsedRangeAndVisibleAnchorsRemainUnchanged root Tests/RepoPromptTests/AgentMode/Transcript/AgentTranscriptWindowedProjectionTests.swift RepoPromptTests.AgentTranscriptWindowedProjectionTests testHiddenAnchorRemapsToCollapsedRangeAndVisibleAnchorsRemainUnchanged AgentMode agent_mode.transcript.tail_windowed_projection scroll_anchor,collapsed_history,tail_window projection_contract root_swiftpm routine 1 Anchors for hidden turns remap to the collapsed range while visible tail anchors retain their original block IDs. Scroll restoration or search focus could target a missing hidden block or corrupt visible-tail anchors. 0.001000 direct_in_process test_case retain 0 PR #391 tail-window transcript rendering ledger reconciliation. +root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testCodemapStatusNotificationsCoalesceRootRowResnapshots root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testCodemapStatusNotificationsCoalesceRootRowResnapshots AgentMode agent_sidebar.codemap.status_coalescing codemap,progress,combine,main_actor,resnapshot,coalescing concurrency_contract root_swiftpm routine 1 PassthroughSubject A same-turn burst of Code Map notifications coalesces into one main-actor root-row resnapshot that reads the latest authoritative status. Uncoalesced progress bursts could accumulate full-root remaps and SwiftUI invalidations faster than the engine publication cadence. direct_in_process test_case retain 0 Incremental Code Map progress sidebar-delivery regression. +root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testCodemapToggleResnapshotsAuthoritativeStateBeforeClearingPending root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testCodemapToggleResnapshotsAuthoritativeStateBeforeClearingPending AgentMode agent_sidebar.codemap.toggle_authority codemap,pause_resume,pending_action state_observation root_swiftpm routine 3 A paused row resumes, the resulting authoritative presentation is resnapshotted before pending state clears, and the next toggle pauses the same root without leaving an in-flight action marker. A stale row could invert pause/resume intent, admit duplicate work, or leave the compact control permanently disabled. direct_in_process test_case retain 0 Per-loaded-root Code Map pause/resume sidebar regression. root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testIndicatorHumanizesMachineFallbackWhileKeepingRawNameAccessible root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testIndicatorHumanizesMachineFallbackWhileKeepingRawNameAccessible AgentMode agent_worktree.identity_label_humanization worktree_indicator,accessibility,tooltip state_observation root_swiftpm routine 1 A machine-generated worktree fallback renders a humanized WT label while rawLabel, tooltip text, and accessibility text retain the full generated name. Raw app-managed worktree leaves could leak into compact sidebar capsules or be lost from recovery/accessibility text. 0.000000 direct_in_process test_case retain 0 Sidebar worktree capsule polish: display-time humanization with raw-name preservation. root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testIndicatorLabelFallsBackToWorktreeIDTail root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testIndicatorLabelFallsBackToWorktreeIDTail AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 261 root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testIndicatorMakeFallsBackToResolvedIdentityForMissingOrInvalidFields root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testIndicatorMakeFallsBackToResolvedIdentityForMissingOrInvalidFields AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 235 @@ -513,6 +515,7 @@ root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testRootContextActionO root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testRowsAttachGitContextByStandardizedRootPathWithoutChangingOrder root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testRowsAttachGitContextByStandardizedRootPathWithoutChangingOrder AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.001000 unreviewed retain_pending_review 0 initial census source line 65 root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testRowsDoNotMarkPrimaryWhenWorkspaceHasNoRoots root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testRowsDoNotMarkPrimaryWhenWorkspaceHasNoRoots AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 45 root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testRowsDoNotMarkSingleRootAsPrimaryOrMovable root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testRowsDoNotMarkSingleRootAsPrimaryOrMovable AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 26 +root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testRowsMapCompactCodemapProgressAndUnavailableState root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testRowsMapCompactCodemapProgressAndUnavailableState AgentMode agent_sidebar.codemap.presentation codemap,progress,local_resolution,durable_coverage,weighted_count,subpercent,unavailable presentation_contract root_swiftpm routine 7 Root snapshots preserve durable coverage while presenting the clamped maximum of durable and locally resolved counts; truthful nonzero progress below one percent renders as <1% without changing its fraction, mapping stays below 100, ready reaches 100, and non-Git unavailability remains disabled. The sidebar could double-count local and accepted work, call local resolution mapped coverage, display real early progress as 0%, render premature completion, or expose controls for unavailable roots. direct_in_process test_case retain 0 Per-loaded-root Code Map compact presentation regression extended for truthful in-batch and subpercent progress. root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testRowsMarkExactlyFirstRootAsPrimaryAcrossMultiRootCounts root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testRowsMarkExactlyFirstRootAsPrimaryAcrossMultiRootCounts AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 52 root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testRowsMarkPrimaryAndMovementForMultipleRoots root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testRowsMarkPrimaryAndMovementForMultipleRoots AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 7 root/RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests/testUnavailableIndicatorOmitsBlankRecoveryPath root Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift RepoPromptTests.AgentWorkspaceRootsSidebarStoreTests testUnavailableIndicatorOmitsBlankRecoveryPath AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 302 @@ -976,6 +979,7 @@ root/RepoPromptTests.CodemapBindingEnginePipelineTests/testOneRootServesMultiple root/RepoPromptTests.CodemapBindingEnginePipelineTests/testPipelineManifestsRemainDistinct root Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEnginePipelineTests.swift RepoPromptTests.CodemapBindingEnginePipelineTests testPipelineManifestsRemainDistinct WorkspaceContext workspace_codemap_binding_engine.pipeline_manifest_isolation swift,typescript,namespace_digest,record_partition persistence_contract root_swiftpm routine 2 ReviewGitRepositoryFixture,CodeMapArtifactRuntime Swift and TypeScript demands under one root persist different manifest namespaces containing only their own records. Pipeline records could overwrite or contaminate another language manifest. git_subprocess,filesystem,actor temporary_directory test_case+fixture_cleanup retain 0 Atomic cutover Slice 1 pipeline-scoped manifests; Moved from root/RepoPromptTests.WorkspaceCodemapBindingEngineTests/testPipelineManifestsRemainDistinct to root/RepoPromptTests.CodemapBindingEnginePipelineTests/testPipelineManifestsRemainDistinct in binding-engine thematic split. root/RepoPromptTests.CodemapBindingEnginePipelineTests/testRootInvalidationRevokesEveryPipeline root Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEnginePipelineTests.swift RepoPromptTests.CodemapBindingEnginePipelineTests testRootInvalidationRevokesEveryPipeline WorkspaceContext workspace_codemap_binding_engine.multi_pipeline_invalidation swift,typescript,repository_authority_invalidation,pre_invalidation_ready,revoked_pipelines,manifest_drain async_state_machine root_swiftpm routine 2 ReviewGitRepositoryFixture,CodeMapArtifactRuntime Swift and TypeScript demands are both ready and visible before repository-authority invalidation; afterward the live snapshot contains neither pipeline, freeze is unavailable, and dirty manifest state is drained. Repository-authority invalidation could revoke only one pipeline, leave a live entry, or retain dirty manifest state. git_subprocess,filesystem,actor,concurrency temporary_directory test_case+fixture_cleanup retain 0 Cutover Slice 1 repository-authority invalidation with pre-ready and post-revocation proof; Moved from root/RepoPromptTests.WorkspaceCodemapBindingEngineTests/testRootInvalidationRevokesEveryPipeline to root/RepoPromptTests.CodemapBindingEnginePipelineTests/testRootInvalidationRevokesEveryPipeline in binding-engine thematic split. root/RepoPromptTests.CodemapBindingEngineProjectionTests/testDemandAdmissionCountsActiveAndDrainingProjectionMaterializationUsage root Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEngineProjectionTests.swift RepoPromptTests.CodemapBindingEngineProjectionTests testDemandAdmissionCountsActiveAndDrainingProjectionMaterializationUsage WorkspaceContext workspace_codemap_binding_engine.projection_usage_demand_admission projection_materialization,active_usage,draining_usage,source_reservation,demand_queue,admission async_resource_lifecycle root_swiftpm routine 3 ReviewGitRepositoryFixture,EngineBuildGate,EngineProjectionRecorder,EngineProjectionCatalogStub A gated projection materialization holds the exact source reservation and queues foreground demand while active; invalidation leaves the same draining reservation and still queues demand; releasing the build admits that demand ready and returns request, queue, and projection resources to zero. Foreground demand could oversubscribe source/materialization limits by ignoring active or canceled-but-draining projection work, or remain stranded after resources drain. git_subprocess,filesystem,artifact_store,actor,concurrency temporary_directory test_case+fixture_cleanup retain 0 2026-06-26 scoped authoritative-census ledger reconciliation; Moved from root/RepoPromptTests.WorkspaceCodemapBindingEngineTests/testDemandAdmissionCountsActiveAndDrainingProjectionMaterializationUsage to root/RepoPromptTests.CodemapBindingEngineProjectionTests/testDemandAdmissionCountsActiveAndDrainingProjectionMaterializationUsage in binding-engine thematic split. +root/RepoPromptTests.CodemapBindingEngineProjectionTests/testInBatchProgressKeepsPageStartBaselineWhenLaterSegmentRetries root Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEngineProjectionTests.swift RepoPromptTests.CodemapBindingEngineProjectionTests testInBatchProgressKeepsPageStartBaselineWhenLaterSegmentRetries WorkspaceContext workspace_codemap_binding_engine.in_batch_page_retry_baseline projection_preload,multi_segment_page,partial_acceptance,retry,page_reread,local_resolution,durable_coverage concurrency_contract root_swiftpm routine 4 ReviewGitRepositoryFixture,EngineAsyncGate,EngineProjectionPartialPageRetryPublisher,EngineProjectionCatalogStub A multi-segment eight-candidate page accepts its first segment, retries a later segment, and rereads the page while preserving the original page-start baseline: durable processed coverage remains partial while all eight candidates are locally resolved, and resolved-through-root remains eight rather than adding the partial durable count again. A retry after partial durable acceptance could baseline ephemeral progress from the advanced durable count and then recount the full page, visibly exceeding truthful through-root work. git_subprocess,filesystem,artifact_store,actor,concurrency temporary_directory test_case+fixture_cleanup retain 0 Regression for stable page-start ephemeral progress across partial segment acceptance and same-page retry. root/RepoPromptTests.CodemapBindingEngineProjectionTests/testNonGitRootBecomesUnavailableWithoutArtifactManifestOrBuildWork root Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEngineProjectionTests.swift RepoPromptTests.CodemapBindingEngineProjectionTests testNonGitRootBecomesUnavailableWithoutArtifactManifestOrBuildWork WorkspaceContext workspace_codemap_binding_engine.non_git_zero_work non_git,terminal_capability,zero_manifest,zero_coordinator,zero_build compatibility_negative root_swiftpm routine 4 ReviewGitRepositoryFixture,CodeMapArtifactRuntime A non-Git root becomes terminally unavailable after capability resolution with no manifest read, coordinator request, or builder execution. Fail-open non-Git handling could invoke artifact infrastructure or publish codemaps outside eligible repositories. git_subprocess;filesystem;actor temporary_directory test_case+fixture_cleanup retain 0 Slice 2B2 inert binding engine orchestration; Moved from root/RepoPromptTests.WorkspaceCodemapBindingEngineTests/testNonGitRootBecomesUnavailableWithoutArtifactManifestOrBuildWork to root/RepoPromptTests.CodemapBindingEngineProjectionTests/testNonGitRootBecomesUnavailableWithoutArtifactManifestOrBuildWork in binding-engine thematic split. root/RepoPromptTests.CodemapBindingEngineProjectionTests/testProjectionDemandClockExpiryBoundsRetryClampAndRevocationAreDeterministic root Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEngineProjectionTests.swift RepoPromptTests.CodemapBindingEngineProjectionTests testProjectionDemandClockExpiryBoundsRetryClampAndRevocationAreDeterministic WorkspaceContext workspace_codemap_binding_engine.projection_demand_bounds_expiry_revocation retained_demand,request_limit,file_id_limit,metadata_limit,retry_clamp,clock_expiry,late_completion,path_revocation bounded_state_machine root_swiftpm routine 6 ReviewGitRepositoryFixture,EngineUptimeClock,EngineAsyncGate,EngineProjectionRecorder A controlled clock proves request-count rejection with the 25 ms retry floor, expiry that releases capacity, exact file-ID and metadata-byte limit diagnostics, expiry when a blocked batch completes after deadline, and stale revocation with exact telemetry after path invalidation. Retained projection demands could exceed count or metadata bounds, spin on undersized retry values, retain capacity past deadline, become ready after late completion, or survive invalidation. git_subprocess,filesystem,artifact_store,actor,concurrency temporary_directory test_case+fixture_cleanup retain 0 2026-06-26 scoped authoritative-census ledger reconciliation; Moved from root/RepoPromptTests.WorkspaceCodemapBindingEngineTests/testProjectionDemandClockExpiryBoundsRetryClampAndRevocationAreDeterministic to root/RepoPromptTests.CodemapBindingEngineProjectionTests/testProjectionDemandClockExpiryBoundsRetryClampAndRevocationAreDeterministic in binding-engine thematic split. root/RepoPromptTests.CodemapBindingEngineProjectionTests/testProjectionDemandEarliestDeadlineOvertakesPreloadAndSharesBackgroundFairnessQuantum root Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEngineProjectionTests.swift RepoPromptTests.CodemapBindingEngineProjectionTests testProjectionDemandEarliestDeadlineOvertakesPreloadAndSharesBackgroundFairnessQuantum WorkspaceContext workspace_codemap_binding_engine.projection_deadline_fairness projection_queue,earliest_deadline,preload,retained_demand,background_fairness,ordinal_rebase concurrency_contract root_swiftpm routine 2 ReviewGitRepositoryFixture,EngineAsyncGate,EngineHookEvents After one blocked preload, queued work is admitted in the exact order earlier-deadline demand, background preload fairness quantum, then later-deadline demand, even with the admission ordinal initialized at overflow. Projection scheduling could ignore retained-demand deadlines, starve background preload work, or corrupt ordering when admission ordinals rebase. git_subprocess,filesystem,artifact_store,actor,concurrency temporary_directory test_case+fixture_cleanup retain 0 2026-06-26 scoped authoritative-census ledger reconciliation; Moved from root/RepoPromptTests.WorkspaceCodemapBindingEngineTests/testProjectionDemandEarliestDeadlineOvertakesPreloadAndSharesBackgroundFairnessQuantum to root/RepoPromptTests.CodemapBindingEngineProjectionTests/testProjectionDemandEarliestDeadlineOvertakesPreloadAndSharesBackgroundFairnessQuantum in binding-engine thematic split. @@ -990,6 +994,7 @@ root/RepoPromptTests.CodemapBindingEngineProjectionTests/testProjectionPreloadUs root/RepoPromptTests.CodemapBindingEngineProjectionTests/testProjectionPreloadWorktreeFileTooLargePublishesTerminalEntry root Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEngineProjectionTests.swift RepoPromptTests.CodemapBindingEngineProjectionTests testProjectionPreloadWorktreeFileTooLargePublishesTerminalEntry WorkspaceContext workspace_codemap_binding_engine.projection_worktree_oversize_terminal projection_preload,dirty_worktree,validated_reader,file_too_large,oversize,terminal_entry,no_build compatibility_negative root_swiftpm routine 1 ReviewGitRepositoryFixture,EngineProjectionRecorder,EngineProjectionCatalogStub A dirty worktree candidate whose validated reader reports fileTooLarge is classified once and published as terminal oversize without starting or invoking a projection build. Oversize worktree files could be treated as transient, retried or parsed repeatedly, or omitted from deterministic projection coverage. git_subprocess,filesystem,artifact_store,actor,concurrency temporary_directory test_case+fixture_cleanup retain 0 2026-06-26 scoped authoritative-census ledger reconciliation; Moved from root/RepoPromptTests.WorkspaceCodemapBindingEngineTests/testProjectionPreloadWorktreeFileTooLargePublishesTerminalEntry to root/RepoPromptTests.CodemapBindingEngineProjectionTests/testProjectionPreloadWorktreeFileTooLargePublishesTerminalEntry in binding-engine thematic split. root/RepoPromptTests.CodemapBindingEngineProjectionTests/testProjectionRescheduleCountsSameRootDrainingSourceAndMaterialization root Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEngineProjectionTests.swift RepoPromptTests.CodemapBindingEngineProjectionTests testProjectionRescheduleCountsSameRootDrainingSourceAndMaterialization WorkspaceContext workspace_codemap_binding_engine.projection_reschedule_draining_resources projection_reschedule,same_root,draining_source,materialization_limit,retry,resource_accounting async_resource_lifecycle root_swiftpm routine 1 ReviewGitRepositoryFixture,EngineMultiEntryGate,EngineProjectionRecorder,EngineProjectionCatalogStub After invalidation cancels a gated preload, a same-root replacement retries rather than acquiring a second source/materialization reservation; only one build enters before release and final projection resources and active batches drain to zero. A canceled-but-draining same-root batch could be omitted from resource bounds, allowing duplicate materialization or leaking reservations after reschedule. git_subprocess,filesystem,artifact_store,actor,concurrency temporary_directory test_case+fixture_cleanup retain 0 2026-06-26 scoped authoritative-census ledger reconciliation; Moved from root/RepoPromptTests.WorkspaceCodemapBindingEngineTests/testProjectionRescheduleCountsSameRootDrainingSourceAndMaterialization to root/RepoPromptTests.CodemapBindingEngineProjectionTests/testProjectionRescheduleCountsSameRootDrainingSourceAndMaterialization in binding-engine thematic split. root/RepoPromptTests.CodemapBindingEngineProjectionTests/testProjectionRescheduleWaitsForSameRootDrainingBatchAdmission root Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEngineProjectionTests.swift RepoPromptTests.CodemapBindingEngineProjectionTests testProjectionRescheduleWaitsForSameRootDrainingBatchAdmission WorkspaceContext workspace_codemap_binding_engine.projection_reschedule_draining_batch_bound projection_reschedule,same_root,draining_batch,per_root_limit,queue,cancellation concurrency_contract root_swiftpm routine 1 ReviewGitRepositoryFixture,EngineMultiEntryGate,EngineProjectionRecorder,EngineProjectionCatalogStub At a one-active-batch-per-root bound, invalidation leaves the first gated batch draining and queues the replacement without starting a second build; release yields one canceled coverage, two schedules, completed replacement coverage, and zero active batches. Rescheduling could ignore a draining batch and violate the per-root active bound, or fail to admit the replacement after the predecessor drains. git_subprocess,filesystem,artifact_store,actor,concurrency temporary_directory test_case+fixture_cleanup retain 0 2026-06-26 scoped authoritative-census ledger reconciliation; Moved from root/RepoPromptTests.WorkspaceCodemapBindingEngineTests/testProjectionRescheduleWaitsForSameRootDrainingBatchAdmission to root/RepoPromptTests.CodemapBindingEngineProjectionTests/testProjectionRescheduleWaitsForSameRootDrainingBatchAdmission in binding-engine thematic split. +root/RepoPromptTests.CodemapBindingEngineProjectionTests/testProjectionSnapshotUpdatesExposeResolvedCandidatesBeforeSingleBatchAcceptance root Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEngineProjectionTests.swift RepoPromptTests.CodemapBindingEngineProjectionTests testProjectionSnapshotUpdatesExposeResolvedCandidatesBeforeSingleBatchAcceptance WorkspaceContext workspace_codemap_binding_engine.in_batch_resolution_progress projection_preload,default_64_batch,local_resolution,durable_coverage,paced_publication,coverage_proof concurrency_contract root_swiftpm routine 3 ReviewGitRepositoryFixture,EngineMultiEntryGate,EngineProjectionRecorder,EngineProjectionCatalogStub With five candidates in one production-sized batch, releasing one async builder exposes local resolved-through-root count one while durable processed coverage and accepted projections remain zero; releasing the rest completes exact proof coverage with no duplicate consecutive snapshots. Single-page repositories could remain visibly at zero until batch acceptance, or local work could be misrepresented as durable proof coverage. git_subprocess,filesystem,artifact_store,actor,concurrency temporary_directory test_case+fixture_cleanup retain 0 Replaces the unledgered hook-blocked intermediate-publication regression with continuation-gated candidate-resolution coverage. root/RepoPromptTests.CodemapBindingEngineProjectionTests/testRetainedProjectionDemandUsesSpareCapacityBeforeForegroundQuantumIsExhausted root Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEngineProjectionTests.swift RepoPromptTests.CodemapBindingEngineProjectionTests testRetainedProjectionDemandUsesSpareCapacityBeforeForegroundQuantumIsExhausted WorkspaceContext workspace_codemap_binding_engine.projection_demand_spare_capacity retained_projection_demand,foreground_request,spare_projection_capacity,fairness_quantum,no_starvation concurrency_contract root_swiftpm routine 1 ReviewGitRepositoryFixture,EngineAsyncGate,EngineProjectionRecorder,EngineHookEvents While a foreground source read occupies its own admission path, retained projection demand uses otherwise spare projection capacity, starts exactly one batch, completes coverage before the foreground request is released, and then releases its ticket. A shared fairness quantum could unnecessarily serialize independent projection capacity behind foreground work and starve retained projection demand. git_subprocess,filesystem,artifact_store,actor,concurrency temporary_directory test_case+fixture_cleanup retain 0 2026-06-26 scoped authoritative-census ledger reconciliation; Moved from root/RepoPromptTests.WorkspaceCodemapBindingEngineTests/testRetainedProjectionDemandUsesSpareCapacityBeforeForegroundQuantumIsExhausted to root/RepoPromptTests.CodemapBindingEngineProjectionTests/testRetainedProjectionDemandUsesSpareCapacityBeforeForegroundQuantumIsExhausted in binding-engine thematic split. root/RepoPromptTests.CodemapBindingEngineRootLeaseTests/testPostCommitManifestAdoptionAuthorityRaceRollsBackOverlaySessionAndLease root Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEngineRootLeaseTests.swift RepoPromptTests.CodemapBindingEngineRootLeaseTests testPostCommitManifestAdoptionAuthorityRaceRollsBackOverlaySessionAndLease WorkspaceContext workspace_codemap_binding_engine.adoption_atomic_rollback post_commit_gate,authority_invalidation,overlay_rollback,session_consistency,lease_release concurrency_contract root_swiftpm routine 2 ReviewGitRepositoryFixture,EngineBuildGate Authority invalidation after the overlay adoption commit but before the engine resumes rolls the adoption back, leaves no ready entry, and releases exact retained lease accounting. A reentrant authority change could leave overlay-ready state without a matching engine session or leak an adopted lease. git_subprocess;filesystem;actor temporary_directory test_case+fixture_cleanup retain 0 Slice 2B2 transactional adoption rollback; Moved from root/RepoPromptTests.WorkspaceCodemapBindingEngineTests/testPostCommitManifestAdoptionAuthorityRaceRollsBackOverlaySessionAndLease to root/RepoPromptTests.CodemapBindingEngineRootLeaseTests/testPostCommitManifestAdoptionAuthorityRaceRollsBackOverlaySessionAndLease in binding-engine thematic split. root/RepoPromptTests.CodemapBindingEngineRootLeaseTests/testSequentialRootsRetainGlobalAdoptionLeaseBudgetUntilUnloadThenRecover root Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEngineRootLeaseTests.swift RepoPromptTests.CodemapBindingEngineRootLeaseTests testSequentialRootsRetainGlobalAdoptionLeaseBudgetUntilUnloadThenRecover WorkspaceContext workspace_codemap_binding_engine.global_adoption_lease_lifetime sequential_roots,global_count_bound,retained_bytes,unload_release,same_session_retry memory_lifetime_contract root_swiftpm routine 3 ReviewGitRepositoryFixture,CodeMapArtifactRuntime A retained warm adoption holds the exact global count and byte budget; a second root's adoption remains retryable under pressure and succeeds in the same root session after unloading the first owner. Transient lease pressure could become a terminal empty adoption, or unload could fail to release capacity for a same-session retry. git_subprocess;filesystem;actor temporary_directory test_case+fixture_cleanup retain 0 Slice 2B2 accounting strengthened for Cutover Slice 1 same-session adoption retry; Moved from root/RepoPromptTests.WorkspaceCodemapBindingEngineTests/testSequentialRootsRetainGlobalAdoptionLeaseBudgetUntilUnloadThenRecover to root/RepoPromptTests.CodemapBindingEngineRootLeaseTests/testSequentialRootsRetainGlobalAdoptionLeaseBudgetUntilUnloadThenRecover in binding-engine thematic split. @@ -1041,11 +1046,15 @@ root/RepoPromptTests.CodemapPreloadTests/testExplicitMaterializationAndRepositor root/RepoPromptTests.CodemapPreloadTests/testFirstExplicitDemandReturnsStableExactRootPendingTicketAndRegistersOnce root Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift RepoPromptTests.CodemapPreloadTests testFirstExplicitDemandReturnsStableExactRootPendingTicketAndRegistersOnce WorkspaceContext/CodeMap codemap.store_seam.exact_pending_registration explicit_demand,stable_ticket,root_epoch,route_registration,single_setup async_concurrency_lifecycle root_swiftpm routine 5 CodemapStoreFixture,CodeMapArtifactRuntimeProvider Duplicate explicit demand shares one exact-root pending ticket, provider access, engine construction, capability resolution, and registry route. Duplicate demand could start competing registration or bind to the wrong root lifetime. filesystem,actor,concurrency test_case+fixture_cleanup+explicit_root_unload retain 0 Slice B inert modern codemap store seam; moved from root/RepoPromptTests.WorkspaceFileContextStoreCodemapSeamTests/testFirstExplicitDemandReturnsStableExactRootPendingTicketAndRegistersOnce to root/RepoPromptTests.CodemapPreloadTests/testFirstExplicitDemandReturnsStableExactRootPendingTicketAndRegistersOnce during codemap seam split root/RepoPromptTests.CodemapPreloadTests/testFirstProjectionPageLazilyPublishesRecordsOnlyShardAfterRootReady root Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift RepoPromptTests.CodemapPreloadTests testFirstProjectionPageLazilyPublishesRecordsOnlyShardAfterRootReady WorkspaceContext/CodeMap codemap.projection_catalog.lazy_records_shard root_ready,lazy_publication,records_only_shard,zero_path_index lazy_initialization_contract root_swiftpm routine 1 CodemapStoreFixture,CodemapResolutionGate No projection shard exists after root load; the first catalog page publishes one records-only exact-epoch shard without building a path index. Root load could eagerly construct the projection catalog or first-page access could build an unnecessary large path index. filesystem,artifact_store,actor,concurrency store_session+registration_gate+root_unload retain 0 Census closure for lazy first projection page.; moved from root/RepoPromptTests.WorkspaceFileContextStoreCodemapSeamTests/testFirstProjectionPageLazilyPublishesRecordsOnlyShardAfterRootReady to root/RepoPromptTests.CodemapPreloadTests/testFirstProjectionPageLazilyPublishesRecordsOnlyShardAfterRootReady during codemap seam split root/RepoPromptTests.CodemapPreloadTests/testLargeRootFirstProjectionShardBuildRunsOffActor root Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift RepoPromptTests.CodemapPreloadTests testLargeRootFirstProjectionShardBuildRunsOffActor WorkspaceContext/CodeMap codemap.projection_catalog.off_actor_large_shard large_root,multi_page_files,blocked_catalog_build,actor_responsiveness,records_only_page concurrency_contract root_swiftpm routine 1 CodemapStoreFixture,CodemapResolutionGate,CodemapRootSuspensionGate While the first multi-page projection shard build is blocked, root inventory, scope availability, and content reads remain responsive; release returns the bounded records-only page. Building a large first shard on the store actor could stall ordinary workspace tools or allocate an unnecessary path index. filesystem,artifact_store,actor,concurrency store_session+catalog_build_gate+root_unload retain 0 Runtime optimization batch reduced synthetic file count while preserving off-actor large-root shard construction coverage.; moved from root/RepoPromptTests.WorkspaceFileContextStoreCodemapSeamTests/testLargeRootFirstProjectionShardBuildRunsOffActor to root/RepoPromptTests.CodemapPreloadTests/testLargeRootFirstProjectionShardBuildRunsOffActor during codemap seam split +root/RepoPromptTests.CodemapPreloadTests/testPauseCancelsBlockedPreloadRejectsDemandAndResumeReschedules root Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift RepoPromptTests.CodemapPreloadTests testPauseCancelsBlockedPreloadRejectsDemandAndResumeReschedules WorkspaceContext codemap.root_pause.admission_control codemap,pause_resume,demand,cleanup_race concurrency_contract root_swiftpm routine 4 Pausing a blocked preload publishes paused state and rejects demand without runtime work; a re-pause wins over an in-flight resume, and a final resume schedules exactly one replacement preload. A paused root could continue generating, admit demand work, or lose the latest suspension intent during cleanup. filesystem;direct_in_process WorkspaceFileContextStore test_case retain 0 Per-loaded-root Code Map suspension and cleanup-race regression. root/RepoPromptTests.CodemapPreloadTests/testProjectionPreloadAndDemandJoinEligibilityAndSetupSingleflights root Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift RepoPromptTests.CodemapPreloadTests testProjectionPreloadAndDemandJoinEligibilityAndSetupSingleflights WorkspaceContext/CodeMap codemap.preload.demand_join_singleflight eligibility_flight,demand_join,setup_singleflight,runtime_singleton,engine_singleton concurrency_contract root_swiftpm routine 2 CodemapStoreFixture,ReviewGitRepositoryFixture,CodemapSuspensionGate A foreground demand joins the blocked preload eligibility flight, then preload handoff and demand setup share one preflight, provider, runtime, engine, and setup task. Concurrent preload and demand could duplicate Git eligibility or process infrastructure and race registrations. filesystem,git_fixture,artifact_store,actor,concurrency store_session+eligibility_gate+demand_owner+root_unload retain 0 Eligibility join and setup-singleflight scenarios.; moved from root/RepoPromptTests.WorkspaceFileContextStoreCodemapSeamTests/testProjectionPreloadAndDemandJoinEligibilityAndSetupSingleflights to root/RepoPromptTests.CodemapPreloadTests/testProjectionPreloadAndDemandJoinEligibilityAndSetupSingleflights during codemap seam split root/RepoPromptTests.CodemapPreloadTests/testProjectionPreloadNonGitEligibilityPerformsZeroRuntimeWorkWithoutDemand root Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift RepoPromptTests.CodemapPreloadTests testProjectionPreloadNonGitEligibilityPerformsZeroRuntimeWorkWithoutDemand WorkspaceContext/CodeMap codemap.preload.non_git_inertness non_git,terminal_eligibility,zero_runtime,zero_manifest,zero_graph dependency_isolation root_swiftpm routine 1 CodemapStoreFixture,CodemapSelectionGraphProbe One non-Git eligibility probe terminally classifies preload without provider, runtime, engine, manifest, build, or graph work. Automatic preload could start expensive codemap infrastructure for roots that cannot support Git-backed projection. filesystem,actor store_session+preload_flight+root_unload retain 0 Census closure for non-Git preload inertness.; moved from root/RepoPromptTests.WorkspaceFileContextStoreCodemapSeamTests/testProjectionPreloadNonGitEligibilityPerformsZeroRuntimeWorkWithoutDemand to root/RepoPromptTests.CodemapPreloadTests/testProjectionPreloadNonGitEligibilityPerformsZeroRuntimeWorkWithoutDemand during codemap seam split root/RepoPromptTests.CodemapPreloadTests/testProjectionPreloadStartIsAfterOrdinaryRootInventorySearchAndReadVisibility root Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift RepoPromptTests.CodemapPreloadTests testProjectionPreloadStartIsAfterOrdinaryRootInventorySearchAndReadVisibility WorkspaceContext/CodeMap codemap.preload.ordinary_visibility_order root_inventory,search_catalog,content_read,scheduled,start_order lifecycle_ordering_contract root_swiftpm routine 2 CodemapStoreFixture,CodemapRootSuspensionGate With preload start blocked, inventory, search, and content read are already usable with zero provider work; release proves root-ready precedes scheduling and scheduling precedes start. Eager preload could delay ordinary root visibility or begin before catalog and search authority are published. filesystem,actor,concurrency store_session+preload_start_gate+root_unload retain 0 Ordinary visibility and exact event-order scenarios.; moved from root/RepoPromptTests.WorkspaceFileContextStoreCodemapSeamTests/testProjectionPreloadStartIsAfterOrdinaryRootInventorySearchAndReadVisibility to root/RepoPromptTests.CodemapPreloadTests/testProjectionPreloadStartIsAfterOrdinaryRootInventorySearchAndReadVisibility during codemap seam split root/RepoPromptTests.CodemapPreloadTests/testRepositoryLayoutChangeDetachesEngineSessionThenRegistersCurrentAuthority root Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift RepoPromptTests.CodemapPreloadTests testRepositoryLayoutChangeDetachesEngineSessionThenRegistersCurrentAuthority WorkspaceContext/CodeMap codemap.preload.repository_authority_reregistration repository_layout,session_detach,stale_ticket,fresh_registration,single_runtime capability_lifecycle_contract root_swiftpm routine 1 CodemapStoreFixture,ReviewGitRepositoryFixture A repository-layout publication detaches the old session, stales its ticket, and registers current authority through a second setup while reusing the process runtime and engine. Repository topology changes could leave old tickets live, skip re-registration, or duplicate process infrastructure. filesystem,git_fixture,artifact_store,actor,concurrency store_session+demand_owner+root_unload retain 0 Census closure for repository-authority session rollover.; moved from root/RepoPromptTests.WorkspaceFileContextStoreCodemapSeamTests/testRepositoryLayoutChangeDetachesEngineSessionThenRegistersCurrentAuthority to root/RepoPromptTests.CodemapPreloadTests/testRepositoryLayoutChangeDetachesEngineSessionThenRegistersCurrentAuthority during codemap seam split root/RepoPromptTests.CodemapPreloadTests/testRootLoadSearchAndReadDoNotInvokeCodemapRuntimeProvider root Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift RepoPromptTests.CodemapPreloadTests testRootLoadSearchAndReadDoNotInvokeCodemapRuntimeProvider WorkspaceContext/CodeMap codemap.store_seam.lazy_inertness root_load,catalog_publication,search,read dependency_isolation root_swiftpm routine 4 CodemapStoreFixture,CodeMapArtifactRuntimeProvider,WorkspaceCodemapSelectionGraphFactory Root loading, catalog publication, indexed search, content read, and unload invoke neither the modern codemap runtime provider nor the injected graph factory. Ordinary workspace activity could eagerly construct process codemap infrastructure or start modern work. filesystem,actor test_case+fixture_cleanup+explicit_root_unload retain 0 Slice B inert modern codemap store seam; moved from root/RepoPromptTests.WorkspaceFileContextStoreCodemapSeamTests/testRootLoadSearchAndReadDoNotInvokeCodemapRuntimeProvider to root/RepoPromptTests.CodemapPreloadTests/testRootLoadSearchAndReadDoNotInvokeCodemapRuntimeProvider during codemap seam split +root/RepoPromptTests.CodemapPreloadTests/testRootStatusNormalizesSuspendedBusyAsGeneratingAndBudgetLimitedAsWaiting root Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift RepoPromptTests.CodemapPreloadTests testRootStatusNormalizesSuspendedBusyAsGeneratingAndBudgetLimitedAsWaiting WorkspaceContext workspace_codemap_store.root_status_phase_normalization projection_preload,suspended_busy,budget_limited,public_status state_projection root_swiftpm routine 2 CodemapStoreFixture A suspended-busy engine snapshot projects as public generating while a budget-limited snapshot remains waiting for the same loaded root. Treating suspended-busy retry work as waiting could falsely imply a visible pause and reintroduce Mapping-to-Waiting flicker, while treating a hard budget boundary as generating could hide the blocked state. filesystem,actor test_case+fixture_cleanup retain 0 Focused Code Map root-status phase normalization and anti-flicker regression. +root/RepoPromptTests.CodemapPreloadTests/testRootStatusStreamBridgesLocalCandidateResolutionUntilDurableCompletion root Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift RepoPromptTests.CodemapPreloadTests testRootStatusStreamBridgesLocalCandidateResolutionUntilDurableCompletion WorkspaceContext workspace_codemap_store.root_status_in_batch_bridge projection_preload,root_status_stream,local_resolution,durable_coverage,completion_cleanup concurrency_contract root_swiftpm routine 2 ReviewGitRepositoryFixture,CodemapStoreFixture,EngineMultiEntryGate The store status stream exposes local resolved-through-root count one while durable processed coverage is zero, then publishes ready proof counts with ephemeral progress cleared after completion. The store bridge could discard perceptible engine progress, overwrite durable count semantics, or retain ephemeral state after proof-bearing completion. git_subprocess,filesystem,artifact_store,actor,concurrency temporary_directory test_case+fixture_cleanup retain 0 Incremental Code Map progress store-bridge regression. +root/RepoPromptTests.CodemapPreloadTests/testSingleFileEditRetainsUnchangedCoverageWhileProjectionReschedules root Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift RepoPromptTests.CodemapPreloadTests testSingleFileEditRetainsUnchangedCoverageWhileProjectionReschedules WorkspaceContext workspace_codemap_store.path_invalidation_status_baseline file_edit,path_invalidation,projection_reschedule,durable_coverage,progress state_projection root_swiftpm routine 1 ReviewGitRepositoryFixture,CodemapStoreFixture,CodemapRootSuspensionGate After complete three-file coverage, editing one supported file exposes two retained unchanged candidates while the replacement preload is blocked, then returns to complete coverage. A one-file watcher invalidation could expose a false whole-root zero-percent restart while unchanged accepted coverage remains valid. git_subprocess,filesystem,artifact_store,actor,concurrency temporary_directory test_case+fixture_cleanup+preload_start_gate retain 0 Single-file Code Map progress continuity regression. root/RepoPromptTests.CodemapPreloadTests/testStaleLifetimeRepositoryDeltaDoesNotAcquireFenceOrDetachCurrentAuthority root Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift RepoPromptTests.CodemapPreloadTests testStaleLifetimeRepositoryDeltaDoesNotAcquireFenceOrDetachCurrentAuthority WorkspaceContext/CodeMap codemap.preload.stale_lifetime_delta stale_lifetime,repository_delta,no_fence,no_detach,no_setup stale_generation_contract root_swiftpm routine 1 CodemapStoreFixture,ReviewGitRepositoryFixture A repository delta carrying a foreign lifetime leaves events and setup counts unchanged and performs no new preflight, provider, runtime, or engine access. A delayed watcher publication could fence or detach the current replacement root lifetime. filesystem,git_fixture,artifact_store,actor,concurrency store_session+root_unload retain 0 Census closure for stale-lifetime repository delta isolation.; moved from root/RepoPromptTests.WorkspaceFileContextStoreCodemapSeamTests/testStaleLifetimeRepositoryDeltaDoesNotAcquireFenceOrDetachCurrentAuthority to root/RepoPromptTests.CodemapPreloadTests/testStaleLifetimeRepositoryDeltaDoesNotAcquireFenceOrDetachCurrentAuthority during codemap seam split root/RepoPromptTests.CodemapPreloadTests/testTransientEligibilityUsesOneAuthorityCheckedBackoffRetry root Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift RepoPromptTests.CodemapPreloadTests testTransientEligibilityUsesOneAuthorityCheckedBackoffRetry WorkspaceContext/CodeMap codemap.preload.eligibility_backoff transient_eligibility,deterministic_clock,busy_demand,single_retry,terminal_non_git retry_policy_contract root_swiftpm routine 2 CodemapStoreFixture,CodemapRetryTestClock,CodemapRetrySleepGate The first transient eligibility result records the exact 100-nanosecond backoff and absolute 1,100-nanosecond deadline and keeps demand busy without re-probing; clock advance performs one retry that terminates non-Git with no provider access. Transient preflight could busy-loop, ignore the authority-checked deadline, or let foreground demand bypass the root backoff. filesystem,actor,concurrency store_session+retry_sleep+root_unload retain 0 Backoff enforcement and single terminal retry scenarios.; moved from root/RepoPromptTests.WorkspaceFileContextStoreCodemapSeamTests/testTransientEligibilityUsesOneAuthorityCheckedBackoffRetry to root/RepoPromptTests.CodemapPreloadTests/testTransientEligibilityUsesOneAuthorityCheckedBackoffRetry during codemap seam split root/RepoPromptTests.CodemapPreloadTests/testTransientSetupUsesOneBackoffThenFreshSetupRegistration root Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift RepoPromptTests.CodemapPreloadTests testTransientSetupUsesOneBackoffThenFreshSetupRegistration WorkspaceContext/CodeMap codemap.preload.setup_backoff setup_failure,deterministic_clock,single_backoff,fresh_setup,handoff retry_policy_contract root_swiftpm routine 1 CodemapStoreFixture,ReviewGitRepositoryFixture,CodemapRetryTestClock,CodemapRetrySleepGate An initial unconfigured runtime failure sleeps for the exact 250-nanosecond backoff, then creates one fresh setup registration that hands off successfully. A transient setup failure could reuse poisoned registration state, retry without backoff, or create extra setup tasks. filesystem,git_fixture,artifact_store,actor,concurrency store_session+retry_sleep+root_unload retain 0 Census closure for transient setup retry.; moved from root/RepoPromptTests.WorkspaceFileContextStoreCodemapSeamTests/testTransientSetupUsesOneBackoffThenFreshSetupRegistration to root/RepoPromptTests.CodemapPreloadTests/testTransientSetupUsesOneBackoffThenFreshSetupRegistration during codemap seam split diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentWorkspaceRootsSidebarStore.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentWorkspaceRootsSidebarStore.swift index 440de66fd..fbe7ae455 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentWorkspaceRootsSidebarStore.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentWorkspaceRootsSidebarStore.swift @@ -1,6 +1,143 @@ import Combine import Foundation +struct AgentWorkspaceCodemapPresentation: Equatable { + enum State: Equatable { + case pending + case mapping + case waiting + case ready + case paused + case unavailable + } + + enum Tone: Equatable { + case accent + case success + case warning + case secondary + } + + let state: State + /// Durable candidate coverage accepted by the projection catalog. + let processedCandidateCount: UInt64 + /// Ephemeral candidates resolved locally in the active batch, expressed through the root. + let locallyResolvedCandidateCountThroughRoot: UInt64? + let totalCandidateCount: UInt64? + + static let pending = Self( + state: .pending, + processedCandidateCount: 0, + locallyResolvedCandidateCountThroughRoot: nil, + totalCandidateCount: nil + ) + + var tone: Tone { + switch state { + case .pending, .mapping: .accent + case .waiting: .warning + case .ready: .success + case .paused, .unavailable: .secondary + } + } + + var isPaused: Bool { + state == .paused + } + + var canToggle: Bool { + state != .unavailable + } + + var isActivelyMapping: Bool { + switch state { + case .pending, .mapping: true + case .waiting, .ready, .paused, .unavailable: false + } + } + + var showsProgress: Bool { + switch state { + case .pending, .mapping, .waiting: true + case .ready, .paused, .unavailable: false + } + } + + var displayProcessedCandidateCount: UInt64 { + let displayed = max( + processedCandidateCount, + locallyResolvedCandidateCountThroughRoot ?? 0 + ) + guard let totalCandidateCount else { return displayed } + return min(displayed, totalCandidateCount) + } + + var progressFraction: Double? { + if state == .ready, totalCandidateCount == 0 { return 1 } + guard let totalCandidateCount, totalCandidateCount > 0 else { return nil } + let fraction = min(1, Double(displayProcessedCandidateCount) / Double(totalCandidateCount)) + return state == .ready ? fraction : min(0.99, fraction) + } + + var percentageText: String? { + progressFraction.map { progress in + if state != .ready, progress > 0, progress < 0.01 { return "<1%" } + let percentage = Int((progress * 100).rounded(.down)) + return "\(state == .ready ? 100 : min(99, percentage))%" + } + } + + var statusText: String { + switch state { + case .pending: "Preparing…" + case .mapping: percentageText.map { "Mapping \($0)" } ?? "Mapping…" + case .waiting: percentageText.map { "Waiting at \($0)" } ?? "Waiting…" + case .ready: "Mapped" + case .paused: "Paused" + case .unavailable: "Unavailable" + } + } + + var tooltip: String { + switch state { + case .pending: + "Code Map generation is preparing." + case .mapping: + if let totalCandidateCount { + "Code Map generation: \(displayProcessedCandidateCount) of \(totalCandidateCount) files processed for mapping (\(percentageText ?? "0%"))." + } else { + "Code Map generation is in progress." + } + case .waiting: + "Code Map generation is waiting to continue." + case .ready: + "Code Map generation is complete: \(displayProcessedCandidateCount) files mapped." + case .paused: + "Paused for this loaded root. Resume to allow Code Map generation." + case .unavailable: + "Code Maps are unavailable because this root is not a Git repository." + } + } + + static func make(_ snapshot: WorkspaceCodemapRootStatusSnapshot?) -> Self { + guard let snapshot else { return .pending } + let state: State = switch snapshot.state { + case .idle, .preparing: .pending + case .generating: .mapping + case .waiting: .waiting + case .ready: .ready + case .paused: .paused + case .unavailable: .unavailable + } + return Self( + state: state, + processedCandidateCount: snapshot.processedCandidateCount, + locallyResolvedCandidateCountThroughRoot: snapshot.locallyResolvedCandidateCountThroughRoot, + totalCandidateCount: snapshot.totalCandidateCount + ) + } +} + struct AgentWorkspaceRootRow: Identifiable, Equatable { let id: UUID let name: String @@ -11,6 +148,7 @@ struct AgentWorkspaceRootRow: Identifiable, Equatable { let canMoveDown: Bool let gitContext: GitWorktreeContextSummary? let worktree: AgentWorktreeIndicator? + let codemap: AgentWorkspaceCodemapPresentation init( id: UUID, @@ -21,7 +159,8 @@ struct AgentWorkspaceRootRow: Identifiable, Equatable { canMoveUp: Bool, canMoveDown: Bool, gitContext: GitWorktreeContextSummary? = nil, - worktree: AgentWorktreeIndicator? = nil + worktree: AgentWorktreeIndicator? = nil, + codemap: AgentWorkspaceCodemapPresentation = .pending ) { self.id = id self.name = name @@ -32,6 +171,7 @@ struct AgentWorkspaceRootRow: Identifiable, Equatable { self.canMoveDown = canMoveDown self.gitContext = gitContext self.worktree = worktree + self.codemap = codemap } func withWorktree(_ worktree: AgentWorktreeIndicator?) -> AgentWorkspaceRootRow { @@ -44,7 +184,8 @@ struct AgentWorkspaceRootRow: Identifiable, Equatable { canMoveUp: canMoveUp, canMoveDown: canMoveDown, gitContext: gitContext, - worktree: worktree + worktree: worktree, + codemap: codemap ) } } @@ -54,11 +195,15 @@ final class AgentWorkspaceRootsSidebarStore: ObservableObject { @Published private(set) var rootRows: [AgentWorkspaceRootRow] = [] @Published private(set) var workspaceLabel = "No Workspace" @Published private(set) var isExitDisabled = true + @Published private(set) var codemapActionRootIDs: Set = [] private let rootProjections: @MainActor () -> [WorkspaceRootShellProjection] private let rootChanges: AnyPublisher private let gitContextLookup: @MainActor (String) -> GitWorktreeContextSummary? private let gitContextChanges: AnyPublisher + private let codemapStatusLookup: @MainActor (UUID) -> WorkspaceCodemapRootStatusSnapshot? + private let codemapStatusChanges: AnyPublisher + private let setCodemapSuspended: @MainActor (UUID, Bool) async -> Void private let workspaceManager: WorkspaceManagerViewModel let windowID: Int @@ -75,6 +220,9 @@ final class AgentWorkspaceRootsSidebarStore: ObservableObject { rootChanges: AnyPublisher, gitContextLookup: @escaping @MainActor (String) -> GitWorktreeContextSummary? = { _ in nil }, gitContextChanges: AnyPublisher = Empty().eraseToAnyPublisher(), + codemapStatusLookup: @escaping @MainActor (UUID) -> WorkspaceCodemapRootStatusSnapshot? = { _ in nil }, + codemapStatusChanges: AnyPublisher = Empty().eraseToAnyPublisher(), + setCodemapSuspended: @escaping @MainActor (UUID, Bool) async -> Void = { _, _ in }, workspaceManager: WorkspaceManagerViewModel, windowID: Int ) { @@ -82,6 +230,9 @@ final class AgentWorkspaceRootsSidebarStore: ObservableObject { self.rootChanges = rootChanges self.gitContextLookup = gitContextLookup self.gitContextChanges = gitContextChanges + self.codemapStatusLookup = codemapStatusLookup + self.codemapStatusChanges = codemapStatusChanges + self.setCodemapSuspended = setCodemapSuspended self.workspaceManager = workspaceManager self.windowID = windowID @@ -97,7 +248,8 @@ final class AgentWorkspaceRootsSidebarStore: ObservableObject { static func rows( from projections: [WorkspaceRootShellProjection], - gitContextLookup: (String) -> GitWorktreeContextSummary? = { _ in nil } + gitContextLookup: (String) -> GitWorktreeContextSummary? = { _ in nil }, + codemapStatusLookup: (UUID) -> WorkspaceCodemapRootStatusSnapshot? = { _ in nil } ) -> [AgentWorkspaceRootRow] { let rootCount = projections.count return projections.enumerated().map { index, projection in @@ -109,7 +261,8 @@ final class AgentWorkspaceRootsSidebarStore: ObservableObject { isPrimary: rootCount > 1 && index == 0, canMoveUp: rootCount > 1 && index > 0, canMoveDown: rootCount > 1 && index < rootCount - 1, - gitContext: gitContextLookup(projection.standardizedFullPath) + gitContext: gitContextLookup(projection.standardizedFullPath), + codemap: AgentWorkspaceCodemapPresentation.make(codemapStatusLookup(projection.id)) ) } } @@ -157,6 +310,21 @@ final class AgentWorkspaceRootsSidebarStore: ObservableObject { } } + func toggleCodemapGeneration(rowID: UUID) async { + guard !codemapActionRootIDs.contains(rowID), + let row = rootRows.first(where: { $0.id == rowID }), + row.codemap.canToggle + else { return } + codemapActionRootIDs.insert(rowID) + defer { codemapActionRootIDs.remove(rowID) } + await setCodemapSuspended(rowID, !row.codemap.isPaused) + resnapshotRootRows() + } + + func isCodemapActionPending(rowID: UUID) -> Bool { + codemapActionRootIDs.contains(rowID) + } + private func observeInputs() { rootChanges .sink { [weak self] in @@ -174,6 +342,14 @@ final class AgentWorkspaceRootsSidebarStore: ObservableObject { } .store(in: &cancellables) + codemapStatusChanges + .sink { [weak self] in + Task { @MainActor in + self?.scheduleRootRowsResnapshot() + } + } + .store(in: &cancellables) + workspaceManager.objectWillChange .sink { [weak self] _ in Task { @MainActor in @@ -208,7 +384,8 @@ final class AgentWorkspaceRootsSidebarStore: ObservableObject { private func resnapshotRootRows() { let nextRootRows = Self.rows( from: rootProjections(), - gitContextLookup: gitContextLookup + gitContextLookup: gitContextLookup, + codemapStatusLookup: codemapStatusLookup ) if rootRows != nextRootRows { diff --git a/Sources/RepoPrompt/Features/AgentMode/Views/AgentModeView.swift b/Sources/RepoPrompt/Features/AgentMode/Views/AgentModeView.swift index 017e2110c..6860e457e 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Views/AgentModeView.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Views/AgentModeView.swift @@ -38,6 +38,14 @@ struct AgentModeView: View { rootChanges: windowState.workspaceFilesViewModel.rootShellProjectionsChangedPublisher, gitContextLookup: { promptManager.gitViewModel.gitWorktreeContext(forStandardizedRootPath: $0) }, gitContextChanges: promptManager.gitViewModel.gitWorktreeContextChanges, + codemapStatusLookup: { windowState.workspaceFilesViewModel.codemapRootStatus(rootID: $0) }, + codemapStatusChanges: windowState.workspaceFilesViewModel.codemapRootStatusesChangedPublisher, + setCodemapSuspended: { rootID, suspended in + await windowState.workspaceFilesViewModel.setCodemapGenerationSuspended( + rootID: rootID, + suspended: suspended + ) + }, workspaceManager: windowState.workspaceManager, windowID: windowState.windowID )) diff --git a/Sources/RepoPrompt/Features/AgentMode/Views/Components/AgentWorkspaceRootsSectionView.swift b/Sources/RepoPrompt/Features/AgentMode/Views/Components/AgentWorkspaceRootsSectionView.swift index 4835f4f49..af4615dfb 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Views/Components/AgentWorkspaceRootsSectionView.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Views/Components/AgentWorkspaceRootsSectionView.swift @@ -32,12 +32,71 @@ struct AgentWorkspaceRootsSectionView: View { @FocusState private var focusedRootAction: RootActionFocus? @State private var showModelsPopover = false @State private var showPermissionsPopover = false + @State private var showCodemapPopover = false @State private var isAddFolderHovered = false @ObservedObject private var fontScale = FontScaleManager.shared private var fontPreset: FontScalePreset { fontScale.preset } + private struct CodemapSummary { + enum State: Equatable { + case mapping + case waiting + case ready + case paused + case mixed + case unavailable + } + + let state: State + let progressFraction: Double? + let processedCandidateCount: UInt64 + let totalCandidateCount: UInt64? + let mappedRootCount: Int + let waitingRootCount: Int + let pausedRootCount: Int + + var progressPercentage: Int? { + guard state == .mapping || state == .waiting, let progressFraction else { return nil } + return min(99, max(0, Int((progressFraction * 100).rounded(.down)))) + } + + var label: String { + switch state { + case .mapping, .waiting, .ready, .unavailable: "Code Map" + case .paused: "Paused" + case .mixed: "Partial" + } + } + + var detailText: String { + switch state { + case .mapping: + var details = [progressPercentage.map { "Mapping \($0)%" } ?? "Preparing mapping…"] + if waitingRootCount > 0 { details.append("\(waitingRootCount) waiting") } + if pausedRootCount > 0 { details.append("\(pausedRootCount) paused") } + return details.joined(separator: " • ") + case .waiting: + let waitingDetail = progressPercentage.map { "Waiting at \($0)%" } ?? "Waiting to continue" + if pausedRootCount > 0 { return "\(waitingDetail) • \(pausedRootCount) paused" } + return waitingDetail + case .ready: + return "All available roots mapped" + case .paused: + return "Mapping paused" + case .mixed: + return "\(mappedRootCount) mapped • \(pausedRootCount) paused" + case .unavailable: + return "Code Maps unavailable" + } + } + + var tooltip: String { + "\(detailText). Click for progress and per-root controls." + } + } + private struct RootActionFocus: Hashable { enum Action: Hashable { case moveUp @@ -185,6 +244,53 @@ struct AgentWorkspaceRootsSectionView: View { } } + private var codemapSummary: CodemapSummary { + let availableRoots = roots.filter(\.codemap.canToggle) + let mappingRoots = availableRoots.filter(\.codemap.isActivelyMapping) + let waitingRoots = availableRoots.filter { $0.codemap.state == .waiting } + let pausedRoots = availableRoots.filter(\.codemap.isPaused) + let mappedRoots = availableRoots.filter { $0.codemap.state == .ready } + let progressRoots = availableRoots.filter { !$0.codemap.isPaused } + let totalsAreKnown = !progressRoots.isEmpty && progressRoots.allSatisfy { + $0.codemap.totalCandidateCount != nil + } + let processed = progressRoots.reduce(UInt64(0)) { + $0 + $1.codemap.displayProcessedCandidateCount + } + let total = totalsAreKnown + ? progressRoots.reduce(UInt64(0)) { $0 + ($1.codemap.totalCandidateCount ?? 0) } + : nil + let rawProgress = total.flatMap { total -> Double? in + guard total > 0 else { return mappingRoots.isEmpty ? 1 : nil } + return min(1, Double(processed) / Double(total)) + } + let state: CodemapSummary.State = if !mappingRoots.isEmpty { + .mapping + } else if !waitingRoots.isEmpty { + .waiting + } else if availableRoots.isEmpty { + .unavailable + } else if pausedRoots.count == availableRoots.count { + .paused + } else if mappedRoots.count == availableRoots.count { + .ready + } else { + .mixed + } + let progress = rawProgress.map { + state == .mapping || state == .waiting ? min(0.99, $0) : $0 + } + return CodemapSummary( + state: state, + progressFraction: progress, + processedCandidateCount: processed, + totalCandidateCount: total, + mappedRootCount: mappedRoots.count, + waitingRootCount: waitingRoots.count, + pausedRootCount: pausedRoots.count + ) + } + /// Resolves the active session's worktree indicator bound to `row`, if any. private func worktreeIndicator(for row: AgentWorkspaceRootRow) -> AgentWorktreeIndicator? { if let direct = worktreeIndicatorsByLogicalRootPath[row.fullPath] { @@ -273,9 +379,15 @@ struct AgentWorkspaceRootsSectionView: View { .foregroundColor(.secondary) .fixedSize(horizontal: true, vertical: false) - workspaceDropdown - .frame(maxWidth: .infinity, alignment: .leading) - .layoutPriority(1) + HStack(spacing: headerButtonSpacing) { + workspaceDropdown + .layoutPriority(1) + + codemapStatusTag + .layoutPriority(2) + } + + Spacer(minLength: 0) Button(action: { Task { await rootsStore.exitWorkspace() } @@ -293,6 +405,206 @@ struct AgentWorkspaceRootsSectionView: View { } } + private var codemapStatusTag: some View { + let summary = codemapSummary + return Button { + showCodemapPopover.toggle() + } label: { + ViewThatFits(in: .horizontal) { + codemapStatusTagLabel(summary, showsText: true) + codemapStatusTagLabel(summary, showsText: false) + } + } + .buttonStyle(CustomButtonStyle(verticalPadding: 0, horizontalPadding: 8, height: 26)) + .hoverTooltip(summary.tooltip, .top) + .accessibilityLabel(summary.tooltip) + .popover(isPresented: $showCodemapPopover, arrowEdge: .top) { + codemapPopoverContent + } + } + + private func codemapStatusTagLabel( + _ summary: CodemapSummary, + showsText: Bool + ) -> some View { + let tint = codemapSummaryTint(summary) + return HStack(spacing: fontPreset.scaledClamped(5, max: 7)) { + if showsText { + Text(summary.label) + .font(fontPreset.swiftUIFont(sizeAtNormal: 10, weight: .medium)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + codemapSummaryIndicator(summary) + } + .foregroundStyle(tint) + } + + @ViewBuilder + private func codemapSummaryIndicator(_ summary: CodemapSummary) -> some View { + let size = fontPreset.scaledClamped(15, min: 15, max: 20) + let tint = codemapSummaryTint(summary) + ZStack { + switch summary.state { + case .mapping, .waiting: + if let progress = summary.progressFraction { + Circle() + .stroke(tint.opacity(0.25), lineWidth: 2) + Circle() + .trim(from: 0, to: progress) + .stroke(tint, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 0.15), value: progress) + if let progressPercentage = summary.progressPercentage { + Text("\(progressPercentage)") + .font(fontPreset.swiftUIFont(sizeAtNormal: 6, weight: .medium)) + .foregroundStyle(tint) + } + } else { + ProgressView() + .controlSize(.mini) + .scaleEffect(0.55) + } + case .ready: + Image(systemName: "checkmark.circle") + .font(fontPreset.swiftUIFont(sizeAtNormal: 13, weight: .medium)) + case .paused: + Image(systemName: "pause.circle.fill") + .font(fontPreset.swiftUIFont(sizeAtNormal: 13, weight: .medium)) + case .mixed: + Image(systemName: "circle.lefthalf.filled") + .font(fontPreset.swiftUIFont(sizeAtNormal: 13, weight: .medium)) + case .unavailable: + Image(systemName: "slash.circle") + .font(fontPreset.swiftUIFont(sizeAtNormal: 13, weight: .medium)) + } + } + .frame(width: size, height: size) + } + + private func codemapSummaryTint(_ summary: CodemapSummary) -> Color { + switch summary.state { + case .mapping: .blue + case .waiting: .orange + case .ready: .secondary + case .mixed: .orange + case .paused, .unavailable: .secondary + } + } + + private var codemapPopoverContent: some View { + let summary = codemapSummary + return VStack(alignment: .leading, spacing: fontPreset.scaledClamped(10, max: 14)) { + HStack(spacing: fontPreset.scaledClamped(8, max: 10)) { + codemapSummaryIndicator(summary) + VStack(alignment: .leading, spacing: 2) { + Text("Code Map Mapping") + .font(fontPreset.swiftUIFont(sizeAtNormal: 13, weight: .semibold)) + Text(summary.detailText) + .font(fontPreset.swiftUIFont(sizeAtNormal: 10)) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + + if summary.state == .mapping || summary.state == .waiting { + if let progress = summary.progressFraction { + ProgressView(value: progress) + .tint(.accentColor) + if let total = summary.totalCandidateCount { + Text("\(summary.processedCandidateCount) of \(total) files processed for mapping") + .font(fontPreset.swiftUIFont(sizeAtNormal: 10)) + .foregroundStyle(.secondary) + } + } else { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text( + summary.pausedRootCount > 0 + ? "Per-root progress continues below." + : "Preparing repository catalogs…" + ) + .font(fontPreset.swiftUIFont(sizeAtNormal: 10)) + .foregroundStyle(.secondary) + } + } + } + + Divider() + + Text("Repositories") + .font(fontPreset.swiftUIFont(sizeAtNormal: 10, weight: .semibold)) + .foregroundStyle(.secondary) + + ScrollView(.vertical) { + LazyVStack(spacing: fontPreset.scaledClamped(6, max: 8)) { + ForEach(roots, id: \.id) { row in + codemapRootPopoverRow(row) + } + } + } + .frame(maxHeight: fontPreset.scaledClamped(260, min: 140, max: 360)) + .scrollIndicators(.automatic) + } + .padding(fontPreset.scaledClamped(14, max: 18)) + .frame(width: fontPreset.scaledClamped(320, min: 290, max: 390)) + } + + private func codemapRootPopoverRow(_ row: AgentWorkspaceRootRow) -> some View { + let pending = rootsStore.isCodemapActionPending(rowID: row.id) + let actionTitle = row.codemap.isPaused ? "Resume" : "Pause" + return VStack(alignment: .leading, spacing: fontPreset.scaledClamped(5, max: 7)) { + HStack(spacing: fontPreset.scaledClamped(7, max: 9)) { + Image(systemName: "folder.fill") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 1) { + Text(row.name) + .font(fontPreset.swiftUIFont(sizeAtNormal: 11, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + Text(row.codemap.statusText) + .font(fontPreset.swiftUIFont(sizeAtNormal: 9)) + .foregroundStyle(codemapTint(row.codemap.tone)) + } + Spacer(minLength: 4) + if row.codemap.canToggle { + Button { + Task { + await rootsStore.toggleCodemapGeneration(rowID: row.id) + } + } label: { + HStack(spacing: 4) { + if pending { + ProgressView() + .controlSize(.mini) + } else { + Image(systemName: row.codemap.isPaused ? "play.fill" : "pause.fill") + } + Text(actionTitle) + } + .font(fontPreset.swiftUIFont(sizeAtNormal: 9, weight: .medium)) + } + .buttonStyle(CustomButtonStyle(verticalPadding: 0, horizontalPadding: 6, height: 22)) + .disabled(pending) + .accessibilityLabel("\(actionTitle) Code Map generation for \(row.name)") + } + } + + if row.codemap.showsProgress, let progress = row.codemap.progressFraction { + ProgressView(value: progress) + .tint(codemapTint(row.codemap.tone)) + } + } + .padding(.horizontal, fontPreset.scaledClamped(8, max: 11)) + .padding(.vertical, fontPreset.scaledClamped(7, max: 9)) + .background( + RoundedRectangle(cornerRadius: fontPreset.scaledClamped(8, max: 11), style: .continuous) + .fill(Color.secondary.opacity(0.08)) + ) + .hoverTooltip(row.codemap.tooltip, .top) + } + // MARK: - Workspace Dropdown private var workspaceDropdown: some View { @@ -483,6 +795,15 @@ struct AgentWorkspaceRootsSectionView: View { .padding(.leading, rootFolderIconWidth + rootRowSpacing) } + private func codemapTint(_ tone: AgentWorkspaceCodemapPresentation.Tone) -> Color { + switch tone { + case .accent: .accentColor + case .success: .green + case .warning: .orange + case .secondary: .secondary + } + } + private func rootActionsOverlay(_ row: AgentWorkspaceRootRow, hasMultipleRoots: Bool) -> some View { HStack(spacing: rootActionOverlaySpacing) { if hasMultipleRoots { @@ -551,6 +872,15 @@ struct AgentWorkspaceRootsSectionView: View { } } + Divider() + + Button(row.codemap.isPaused ? "Resume Code Map Generation" : "Pause Code Map Generation") { + Task { + await rootsStore.toggleCodemapGeneration(rowID: row.id) + } + } + .disabled(rootsStore.isCodemapActionPending(rowID: row.id) || !row.codemap.canToggle) + if let worktree = row.worktree { Divider() diff --git a/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift b/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift index 7e6358677..a4f9e9b6a 100644 --- a/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift +++ b/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift @@ -730,6 +730,9 @@ class WorkspaceFilesViewModel: ObservableObject { var onRootFoldersChanged: (() -> Void)? private let rootShellProjectionsChangedSubject = PassthroughSubject() + private let codemapRootStatusesChangedSubject = PassthroughSubject() + private var codemapRootStatusesByRootID: [UUID: WorkspaceCodemapRootStatusSnapshot] = [:] + private var acceptedCodemapRootStatusRevision: UInt64? private var rootShellProjectionChangeBatchDepth = 0 private var hasPendingRootShellProjectionChange = false @@ -737,6 +740,23 @@ class WorkspaceFilesViewModel: ObservableObject { rootShellProjectionsChangedSubject.eraseToAnyPublisher() } + var codemapRootStatusesChangedPublisher: AnyPublisher { + codemapRootStatusesChangedSubject.eraseToAnyPublisher() + } + + func codemapRootStatus(rootID: UUID) -> WorkspaceCodemapRootStatusSnapshot? { + codemapRootStatusesByRootID[rootID] + } + + func setCodemapGenerationSuspended(rootID: UUID, suspended: Bool) async { + _ = await workspaceFileContextStore.setCodemapGenerationSuspended( + rootID: rootID, + suspended: suspended + ) + let update = await workspaceFileContextStore.currentCodemapRootStatusUpdate() + handleCodemapRootStatus(update) + } + @MainActor func beginRootShellProjectionChangeBatch() { rootShellProjectionChangeBatchDepth += 1 @@ -1222,6 +1242,7 @@ class WorkspaceFilesViewModel: ObservableObject { private var workspaceStoreDeltaBridgeTask: Task? private var codemapSelectionGraphReadinessTask: Task? private var codemapMarkerReadinessTask: Task? + private var codemapRootStatusTask: Task? private let alwaysReadableHomeDirectoryURL: URL private let automaticCodemapSelectionRequestPolicy: WorkspaceCodemapAutomaticSelectionRequestPolicy private let automaticCodemapSelectionWaiter: WorkspaceCodemapAutomaticSelectionWaiter @@ -1250,6 +1271,7 @@ class WorkspaceFilesViewModel: ObservableObject { subscribeToWorkspaceStoreDeltaEvents() subscribeToCodemapSelectionGraphReadinessUpdates() subscribeToCodemapMarkerReadinessUpdates() + subscribeToCodemapRootStatusUpdates() subscribeToPartitionStoreSaves() subscribeToFileSystemPreferenceChanges() } @@ -1268,6 +1290,7 @@ class WorkspaceFilesViewModel: ObservableObject { workspaceStoreDeltaBridgeTask?.cancel() codemapSelectionGraphReadinessTask?.cancel() codemapMarkerReadinessTask?.cancel() + codemapRootStatusTask?.cancel() autoCodemapSyncTask?.cancel() for task in sliceRebaseTasksByFullPath.values { task.cancel() @@ -1465,6 +1488,17 @@ class WorkspaceFilesViewModel: ObservableObject { } } + private func subscribeToCodemapRootStatusUpdates() { + let store = workspaceFileContextStore + codemapRootStatusTask = Task { [weak self, store] in + let stream = await store.codemapRootStatusUpdates() + for await update in stream { + guard let self else { return } + handleCodemapRootStatus(update) + } + } + } + private struct WorkspaceAppliedIndexModificationTargets { let filesByID: [UUID: FileViewModel] let foldersByID: [UUID: FolderViewModel] @@ -2465,6 +2499,22 @@ class WorkspaceFilesViewModel: ObservableObject { codemapMarkerReadinessRevision &+= 1 } + @MainActor + private func handleCodemapRootStatus(_ update: WorkspaceCodemapRootStatusUpdate) { + if let acceptedCodemapRootStatusRevision, + update.revision <= acceptedCodemapRootStatusRevision + { + return + } + acceptedCodemapRootStatusRevision = update.revision + let next = Dictionary(uniqueKeysWithValues: update.roots.map { + ($0.rootEpoch.rootID, $0) + }) + guard next != codemapRootStatusesByRootID else { return } + codemapRootStatusesByRootID = next + codemapRootStatusesChangedSubject.send(()) + } + func cancelAllLoadingTasks() { invalidateAllRootLoadTokens() // Cancel the currently running folder loading task, if any. diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceCodemapBindingEngineModels.swift b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceCodemapBindingEngineModels.swift index 336434a7f..e7f5e47d7 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceCodemapBindingEngineModels.swift +++ b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceCodemapBindingEngineModels.swift @@ -43,6 +43,7 @@ struct WorkspaceCodemapBindingEnginePolicy: Equatable { let maximumProjectionCatalogPageEntryCount: Int let maximumProjectionCatalogPagePathByteCount: UInt64 let maximumProjectionBatchCandidateCount: Int + let projectionProgressPublicationMinimumIntervalMilliseconds: UInt64 let maximumRetainedProjectionByteCountPerSegment: UInt64 let maximumRetainedProjectionByteCountPerRoot: UInt64 let maximumRetainedProjectionByteCount: UInt64 @@ -96,6 +97,7 @@ struct WorkspaceCodemapBindingEnginePolicy: Equatable { maximumProjectionCatalogPageEntryCount: Int = 64, maximumProjectionCatalogPagePathByteCount: UInt64 = 256 * 1024, maximumProjectionBatchCandidateCount: Int = 64, + projectionProgressPublicationMinimumIntervalMilliseconds: UInt64 = 100, maximumRetainedProjectionByteCountPerSegment: UInt64 = 8 * 1024 * 1024, maximumRetainedProjectionByteCountPerRoot: UInt64 = 32 * 1024 * 1024, maximumRetainedProjectionByteCount: UInt64 = 128 * 1024 * 1024, @@ -151,6 +153,7 @@ struct WorkspaceCodemapBindingEnginePolicy: Equatable { precondition(maximumProjectionCatalogPagePathByteCount > 0) precondition(maximumProjectionBatchCandidateCount > 0) precondition(maximumProjectionBatchCandidateCount <= maximumProjectionCatalogPageEntryCount) + precondition((25 ... 1000).contains(projectionProgressPublicationMinimumIntervalMilliseconds)) precondition(maximumRetainedProjectionByteCountPerSegment > 0) precondition(maximumRetainedProjectionByteCountPerRoot > 0) precondition(maximumRetainedProjectionByteCount > 0) @@ -209,6 +212,8 @@ struct WorkspaceCodemapBindingEnginePolicy: Equatable { self.maximumProjectionCatalogPageEntryCount = maximumProjectionCatalogPageEntryCount self.maximumProjectionCatalogPagePathByteCount = maximumProjectionCatalogPagePathByteCount self.maximumProjectionBatchCandidateCount = maximumProjectionBatchCandidateCount + self.projectionProgressPublicationMinimumIntervalMilliseconds = + projectionProgressPublicationMinimumIntervalMilliseconds self.maximumRetainedProjectionByteCountPerSegment = maximumRetainedProjectionByteCountPerSegment self.maximumRetainedProjectionByteCountPerRoot = maximumRetainedProjectionByteCountPerRoot self.maximumRetainedProjectionByteCount = maximumRetainedProjectionByteCount @@ -805,6 +810,7 @@ enum WorkspaceCodemapCurrentProjectionSnapshot: Hashable { case pending( phase: WorkspaceCodemapProjectionPreloadPhase, progress: WorkspaceCodemapProjectionProgress, + inBatchProgress: WorkspaceCodemapProjectionInBatchProgress?, retry: WorkspaceCodemapProjectionRetry?, budget: WorkspaceCodemapProjectionBudget? ) diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceCodemapProjectionPreloadModels.swift b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceCodemapProjectionPreloadModels.swift index a3bc7a199..f5ab9445c 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceCodemapProjectionPreloadModels.swift +++ b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceCodemapProjectionPreloadModels.swift @@ -436,6 +436,45 @@ struct WorkspaceCodemapProjectionRetry: Hashable { let nextEligibleAdmissionUptimeNanoseconds: UInt64? } +struct WorkspaceCodemapProjectionInBatchProgress: Hashable { + let batchID: UUID + let acceptedProcessedCandidateBaseline: UInt64 + let resolvedCandidateCount: UInt64 + let candidateCount: UInt64 + + init( + batchID: UUID, + acceptedProcessedCandidateBaseline: UInt64, + resolvedCandidateCount: UInt64, + candidateCount: UInt64 + ) { + precondition(resolvedCandidateCount <= candidateCount) + precondition(acceptedProcessedCandidateBaseline <= UInt64.max - resolvedCandidateCount) + self.batchID = batchID + self.acceptedProcessedCandidateBaseline = acceptedProcessedCandidateBaseline + self.resolvedCandidateCount = resolvedCandidateCount + self.candidateCount = candidateCount + } + + var locallyResolvedCandidateCountThroughRoot: UInt64 { + acceptedProcessedCandidateBaseline + resolvedCandidateCount + } + + func recordingResolvedCandidate() -> Self? { + guard resolvedCandidateCount < candidateCount else { return nil } + let (nextResolvedCandidateCount, overflow) = resolvedCandidateCount.addingReportingOverflow(1) + guard !overflow, + acceptedProcessedCandidateBaseline <= UInt64.max - nextResolvedCandidateCount + else { return nil } + return Self( + batchID: batchID, + acceptedProcessedCandidateBaseline: acceptedProcessedCandidateBaseline, + resolvedCandidateCount: nextResolvedCandidateCount, + candidateCount: candidateCount + ) + } +} + struct WorkspaceCodemapProjectionProgress: Hashable { static let notStarted = Self( phase: .scheduled, diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceCodemapRootStatusModels.swift b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceCodemapRootStatusModels.swift new file mode 100644 index 000000000..812e05272 --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceCodemapRootStatusModels.swift @@ -0,0 +1,55 @@ +import Foundation + +enum WorkspaceCodemapRootStatusState: Hashable { + case idle + case preparing + case generating + case waiting + case ready + case paused + case unavailable +} + +struct WorkspaceCodemapRootStatusSnapshot: Hashable { + let rootEpoch: WorkspaceCodemapRootEpoch + let state: WorkspaceCodemapRootStatusState + /// Durable candidate coverage accepted by the projection catalog. + let processedCandidateCount: UInt64 + /// Ephemeral candidates resolved locally in the active batch, expressed through the root. + let locallyResolvedCandidateCountThroughRoot: UInt64? + let totalCandidateCount: UInt64? + + init( + rootEpoch: WorkspaceCodemapRootEpoch, + state: WorkspaceCodemapRootStatusState, + processedCandidateCount: UInt64, + locallyResolvedCandidateCountThroughRoot: UInt64? = nil, + totalCandidateCount: UInt64? + ) { + self.rootEpoch = rootEpoch + self.state = state + self.processedCandidateCount = processedCandidateCount + self.locallyResolvedCandidateCountThroughRoot = locallyResolvedCandidateCountThroughRoot + self.totalCandidateCount = totalCandidateCount + } + + var displayProcessedCandidateCount: UInt64 { + let displayed = max( + processedCandidateCount, + locallyResolvedCandidateCountThroughRoot ?? 0 + ) + guard let totalCandidateCount else { return displayed } + return min(displayed, totalCandidateCount) + } +} + +struct WorkspaceCodemapRootStatusUpdate: Hashable { + let revision: UInt64 + let roots: [WorkspaceCodemapRootStatusSnapshot] +} + +enum WorkspaceCodemapRootSuspensionUpdateResult: Hashable { + case changed + case unchanged + case rootUnavailable +} diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceCodemapBindingEngine.swift b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceCodemapBindingEngine.swift index a5870237f..fbc220689 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceCodemapBindingEngine.swift +++ b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceCodemapBindingEngine.swift @@ -338,6 +338,13 @@ actor WorkspaceCodemapBindingEngine { let terminalOrdinal: UInt64 } + private struct ProjectionProgressPublicationSchedule { + let token: UUID + let jobID: UUID + let batchID: UUID + let task: Task + } + private struct ProjectionPreloadJob { let id: UUID let rootEpoch: WorkspaceCodemapRootEpoch @@ -352,6 +359,8 @@ actor WorkspaceCodemapBindingEngine { var cursor: WorkspaceCodemapProjectionCatalogCursor? var lastProcessedCursor: WorkspaceCodemapProjectionCatalogCursor? var progress: WorkspaceCodemapProjectionProgress + var inBatchProgress: WorkspaceCodemapProjectionInBatchProgress? + var pageStartProcessedCandidateBaseline: UInt64? var nextSegmentSequence: UInt64 var pipelineScopes: [CodeMapPipelineIdentity: WorkspaceCodemapProjectionPipelineScope] var resources: WorkspaceCodemapProjectionResourceAccounting @@ -504,6 +513,18 @@ actor WorkspaceCodemapBindingEngine { private var manifestAdoptionOperations: [PipelineScope: ManifestAdoptionOperation] = [:] private var drainingManifestAdoptionTasks: [UUID: Task] = [:] private var projectionJobs: [WorkspaceCodemapRootEpoch: ProjectionPreloadJob] = [:] + private var projectionSnapshotContinuations: [ + WorkspaceCodemapRootEpoch: [UUID: AsyncStream.Continuation] + ] = [:] + private var lastPublishedProjectionSnapshotsByRootEpoch: [ + WorkspaceCodemapRootEpoch: WorkspaceCodemapCurrentProjectionSnapshot + ] = [:] + private var projectionProgressPublicationSchedulesByRootEpoch: [ + WorkspaceCodemapRootEpoch: ProjectionProgressPublicationSchedule + ] = [:] + private var lastProjectionProgressPublicationUptimeByRootEpoch: [ + WorkspaceCodemapRootEpoch: UInt64 + ] = [:] private var latestOverlayContributionGenerationByRootEpoch: [ WorkspaceCodemapRootEpoch: WorkspaceCodemapSelectionGraphContributionGeneration ] = [:] @@ -752,6 +773,8 @@ actor WorkspaceCodemapBindingEngine { cursor: nil, lastProcessedCursor: nil, progress: .notStarted, + inBatchProgress: nil, + pageStartProcessedCandidateBaseline: nil, nextSegmentSequence: 0, pipelineScopes: [:], resources: .zero, @@ -780,6 +803,10 @@ actor WorkspaceCodemapBindingEngine { return .handedOff } + func cancelProjectionPreload(rootEpoch: WorkspaceCodemapRootEpoch) { + _ = cancelProjectionJob(rootEpoch: rootEpoch, terminalPhase: .cancelled) + } + func acquireProjectionDemand( rootEpoch: WorkspaceCodemapRootEpoch, fileIDs: [UUID], @@ -879,7 +906,15 @@ actor WorkspaceCodemapBindingEngine { ) let ordinal = nextProjectionDemandOrdinal nextProjectionDemandOrdinal = addingChecked(nextProjectionDemandOrdinal, 1) ?? .max - let joinedExistingFlight = projectionJobs[rootEpoch] != nil + let joinedExistingFlight = projectionJobs[rootEpoch].map { job in + guard projectionJobIsCurrent(job) else { return false } + return switch job.phase { + case .budgetLimited, .complete, .cancelled, .superseded: + false + default: + true + } + } ?? false projectionDemands[ticket.id] = ProjectionDemandRecord( ticket: ticket, owner: owner, @@ -1232,6 +1267,18 @@ actor WorkspaceCodemapBindingEngine { projectionDemands.removeAll() terminalProjectionDemands.removeAll() pruneAdmissionHistory() + for continuations in projectionSnapshotContinuations.values { + for continuation in continuations.values { + continuation.finish() + } + } + projectionSnapshotContinuations.removeAll() + lastPublishedProjectionSnapshotsByRootEpoch.removeAll() + for schedule in projectionProgressPublicationSchedulesByRootEpoch.values { + schedule.task.cancel() + } + projectionProgressPublicationSchedulesByRootEpoch.removeAll() + lastProjectionProgressPublicationUptimeByRootEpoch.removeAll() shutdownComplete = true let waiters = shutdownWaiters shutdownWaiters.removeAll() @@ -1748,6 +1795,7 @@ actor WorkspaceCodemapBindingEngine { return .pending( phase: job.phase, progress: job.progress, + inBatchProgress: job.inBatchProgress, retry: job.retry, budget: job.budget ) @@ -1769,6 +1817,208 @@ actor WorkspaceCodemapBindingEngine { ) } + func projectionSnapshotUpdates( + rootEpoch: WorkspaceCodemapRootEpoch + ) -> AsyncStream { + let id = UUID() + return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + let hadSubscribers = projectionSnapshotContinuations[rootEpoch]?.isEmpty == false + let snapshot = currentProjectionSnapshot(rootEpoch: rootEpoch) + if hadSubscribers { + // Bring existing subscribers and the global de-dup baseline to the same + // current value before the new subscriber receives its initial snapshot. + publishProjectionSnapshotUpdate(rootEpoch: rootEpoch) + } + projectionSnapshotContinuations[rootEpoch, default: [:]][id] = continuation + continuation.yield(snapshot) + if !hadSubscribers { + lastPublishedProjectionSnapshotsByRootEpoch[rootEpoch] = snapshot + } + continuation.onTermination = { _ in + Task { await self.removeProjectionSnapshotContinuation(id, rootEpoch: rootEpoch) } + } + } + } + + private func removeProjectionSnapshotContinuation( + _ id: UUID, + rootEpoch: WorkspaceCodemapRootEpoch + ) { + projectionSnapshotContinuations[rootEpoch]?.removeValue(forKey: id) + if projectionSnapshotContinuations[rootEpoch]?.isEmpty == true { + projectionSnapshotContinuations.removeValue(forKey: rootEpoch) + lastPublishedProjectionSnapshotsByRootEpoch.removeValue(forKey: rootEpoch) + } + } + + private func publishProjectionSnapshotUpdate( + rootEpoch: WorkspaceCodemapRootEpoch, + finish: Bool = false + ) { + guard let continuations = projectionSnapshotContinuations[rootEpoch] else { return } + let snapshot = currentProjectionSnapshot(rootEpoch: rootEpoch) + if lastPublishedProjectionSnapshotsByRootEpoch[rootEpoch] != snapshot { + lastPublishedProjectionSnapshotsByRootEpoch[rootEpoch] = snapshot + for continuation in continuations.values { + continuation.yield(snapshot) + } + } + if finish { + for continuation in continuations.values { + continuation.finish() + } + projectionSnapshotContinuations.removeValue(forKey: rootEpoch) + lastPublishedProjectionSnapshotsByRootEpoch.removeValue(forKey: rootEpoch) + } + } + + private func beginProjectionInBatchProgress( + jobID: UUID, + rootEpoch: WorkspaceCodemapRootEpoch, + candidateCount: Int + ) -> UUID? { + guard candidateCount > 0, + let candidateCount = UInt64(exactly: candidateCount), + var job = projectionJobs[rootEpoch], + job.id == jobID, + projectionJobIsCurrent(job) + else { return nil } + let replacedVisibleProgress = job.inBatchProgress != nil + cancelProjectionProgressPublication(rootEpoch: rootEpoch, resetLastPublication: true) + let batchID = UUID() + let pageStartProcessedCandidateBaseline = job.pageStartProcessedCandidateBaseline + ?? job.checkpoint?.progress.counts.processedCandidateCount + ?? job.progress.counts.processedCandidateCount + job.pageStartProcessedCandidateBaseline = pageStartProcessedCandidateBaseline + job.inBatchProgress = WorkspaceCodemapProjectionInBatchProgress( + batchID: batchID, + acceptedProcessedCandidateBaseline: pageStartProcessedCandidateBaseline, + resolvedCandidateCount: 0, + candidateCount: candidateCount + ) + projectionJobs[rootEpoch] = job + if replacedVisibleProgress { + publishProjectionSnapshotUpdate(rootEpoch: rootEpoch) + } + return batchID + } + + private func recordProjectionCandidateResolved( + jobID: UUID, + rootEpoch: WorkspaceCodemapRootEpoch, + batchID: UUID + ) { + guard var job = projectionJobs[rootEpoch], + job.id == jobID, + projectionJobIsCurrent(job), + let inBatchProgress = job.inBatchProgress, + inBatchProgress.batchID == batchID, + let advanced = inBatchProgress.recordingResolvedCandidate() + else { return } + job.inBatchProgress = advanced + projectionJobs[rootEpoch] = job + scheduleProjectionProgressPublication( + jobID: jobID, + rootEpoch: rootEpoch, + inBatchProgress: advanced + ) + } + + private func scheduleProjectionProgressPublication( + jobID: UUID, + rootEpoch: WorkspaceCodemapRootEpoch, + inBatchProgress: WorkspaceCodemapProjectionInBatchProgress + ) { + let now = uptimeNanoseconds() + if inBatchProgress.resolvedCandidateCount == 1 || + lastProjectionProgressPublicationUptimeByRootEpoch[rootEpoch] == nil + { + cancelProjectionProgressPublication(rootEpoch: rootEpoch, resetLastPublication: false) + lastProjectionProgressPublicationUptimeByRootEpoch[rootEpoch] = now + publishProjectionSnapshotUpdate(rootEpoch: rootEpoch) + return + } + guard projectionProgressPublicationSchedulesByRootEpoch[rootEpoch] == nil else { return } + let interval = policy.projectionProgressPublicationMinimumIntervalMilliseconds * 1_000_000 + let last = lastProjectionProgressPublicationUptimeByRootEpoch[rootEpoch] ?? now + let nextEligible = addingSaturating(last, interval) + if now >= nextEligible { + lastProjectionProgressPublicationUptimeByRootEpoch[rootEpoch] = now + publishProjectionSnapshotUpdate(rootEpoch: rootEpoch) + return + } + let delay = nextEligible - now + let token = UUID() + let batchID = inBatchProgress.batchID + let task = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: delay) + } catch { + return + } + guard !Task.isCancelled else { return } + await self?.publishScheduledProjectionProgress( + token: token, + jobID: jobID, + rootEpoch: rootEpoch, + batchID: batchID + ) + } + projectionProgressPublicationSchedulesByRootEpoch[rootEpoch] = + ProjectionProgressPublicationSchedule( + token: token, + jobID: jobID, + batchID: batchID, + task: task + ) + } + + private func publishScheduledProjectionProgress( + token: UUID, + jobID: UUID, + rootEpoch: WorkspaceCodemapRootEpoch, + batchID: UUID + ) { + guard let schedule = projectionProgressPublicationSchedulesByRootEpoch[rootEpoch], + schedule.token == token, + schedule.jobID == jobID, + schedule.batchID == batchID, + let job = currentProjectionJob(jobID: jobID, rootEpoch: rootEpoch), + job.inBatchProgress?.batchID == batchID + else { return } + projectionProgressPublicationSchedulesByRootEpoch.removeValue(forKey: rootEpoch) + lastProjectionProgressPublicationUptimeByRootEpoch[rootEpoch] = uptimeNanoseconds() + publishProjectionSnapshotUpdate(rootEpoch: rootEpoch) + } + + private func clearProjectionInBatchProgress( + jobID: UUID, + rootEpoch: WorkspaceCodemapRootEpoch, + publishReset: Bool + ) { + guard var job = projectionJobs[rootEpoch], job.id == jobID else { + cancelProjectionProgressPublication(rootEpoch: rootEpoch, resetLastPublication: true) + return + } + let hadVisibleProgress = job.inBatchProgress != nil + job.inBatchProgress = nil + projectionJobs[rootEpoch] = job + cancelProjectionProgressPublication(rootEpoch: rootEpoch, resetLastPublication: true) + if publishReset, hadVisibleProgress { + publishProjectionSnapshotUpdate(rootEpoch: rootEpoch) + } + } + + private func cancelProjectionProgressPublication( + rootEpoch: WorkspaceCodemapRootEpoch, + resetLastPublication: Bool + ) { + projectionProgressPublicationSchedulesByRootEpoch.removeValue(forKey: rootEpoch)?.task.cancel() + if resetLastPublication { + lastProjectionProgressPublicationUptimeByRootEpoch.removeValue(forKey: rootEpoch) + } + } + func accounting() -> WorkspaceCodemapBindingEngineAccounting { expireProjectionDemands() var eligible = 0 @@ -2286,6 +2536,11 @@ actor WorkspaceCodemapBindingEngine { case .complete, .budgetLimited, .cancelled, .superseded: return case .retry: + clearProjectionInBatchProgress( + jobID: jobID, + rootEpoch: rootEpoch, + publishReset: true + ) guard await waitForProjectionRetry(jobID: jobID, rootEpoch: rootEpoch) else { return } @@ -2621,6 +2876,12 @@ actor WorkspaceCodemapBindingEngine { projectionPhase: .readingCatalogPage ) + let inBatchID = beginProjectionInBatchProgress( + jobID: jobID, + rootEpoch: rootEpoch, + candidateCount: page.entries.count + ) + guard page.entries.isEmpty || inBatchID != nil else { return .cancelled } guard updateProjectionPhase(jobID: jobID, rootEpoch: rootEpoch, phase: .loadingEnvelopes) else { return .cancelled } @@ -2687,6 +2948,13 @@ actor WorkspaceCodemapBindingEngine { record: record ) resolvedByFileID[candidate.identity.fileID] = .entry(entry, manifestRecord: nil) + if let inBatchID { + recordProjectionCandidateResolved( + jobID: jobID, + rootEpoch: rootEpoch, + batchID: inBatchID + ) + } } else { misses.append(candidate) } @@ -2757,6 +3025,13 @@ actor WorkspaceCodemapBindingEngine { } while let result = await group.next() { + if case .entry = result.resolution, let inBatchID { + recordProjectionCandidateResolved( + jobID: jobID, + rootEpoch: rootEpoch, + batchID: inBatchID + ) + } completed.append(result) guard nextIndex < misses.count else { continue } let index = nextIndex @@ -3041,6 +3316,11 @@ actor WorkspaceCodemapBindingEngine { job.nextSegmentSequence += 1 job.retry = nil projectionJobs[rootEpoch] = job + cancelProjectionProgressPublication( + rootEpoch: rootEpoch, + resetLastPublication: false + ) + publishProjectionSnapshotUpdate(rootEpoch: rootEpoch) incrementCounter(\.projectionSegmentsPublished) addToCounter(\.projectionSegmentBytes, group.byteCount) if job.nextSegmentSequence == 1 { @@ -3102,10 +3382,14 @@ actor WorkspaceCodemapBindingEngine { progress = projectionProgress(progress, phase: .checkpointed) job.phase = .checkpointed job.progress = progress + job.inBatchProgress = nil + job.pageStartProcessedCandidateBaseline = nil job.retryAttempt = 0 job.retry = nil job.checkpoint = makeProjectionCheckpoint(job) projectionJobs[rootEpoch] = job + cancelProjectionProgressPublication(rootEpoch: rootEpoch, resetLastPublication: true) + publishProjectionSnapshotUpdate(rootEpoch: rootEpoch) guard page.isEnd else { return .checkpointed } guard let completion = catalogCompletion, @@ -3128,11 +3412,15 @@ actor WorkspaceCodemapBindingEngine { case let .accepted(accepted), let .exactDuplicate(accepted): completedJob.phase = .complete completedJob.progress = accepted + completedJob.inBatchProgress = nil + completedJob.pageStartProcessedCandidateBaseline = nil completedJob.coverageProof = proof completedJob.coverageCompletedUptimeNanoseconds = uptimeNanoseconds() completedJob.retry = nil completedJob.checkpoint = makeProjectionCheckpoint(completedJob) projectionJobs[rootEpoch] = completedJob + cancelProjectionProgressPublication(rootEpoch: rootEpoch, resetLastPublication: true) + publishProjectionSnapshotUpdate(rootEpoch: rootEpoch) incrementCounter(\.projectionCoveragesCompleted) emit(.projectionCoverageComplete, rootEpoch: rootEpoch, projectionPhase: .complete) return .complete @@ -3890,7 +4178,7 @@ actor WorkspaceCodemapBindingEngine { let nanoseconds = delay.multipliedReportingOverflow(by: 1_000_000).overflow ? UInt64.max : delay * 1_000_000 - let now = DispatchTime.now().uptimeNanoseconds + let now = uptimeNanoseconds() let next = addingSaturating(now, nanoseconds) job.phase = .suspendedBusy job.retryAttempt = attempt @@ -3981,6 +4269,8 @@ actor WorkspaceCodemapBindingEngine { job.cursor = nil job.lastProcessedCursor = nil job.progress = .notStarted + job.inBatchProgress = nil + job.pageStartProcessedCandidateBaseline = nil job.nextSegmentSequence = 0 job.pipelineScopes = [:] job.resources = .zero @@ -3994,6 +4284,8 @@ actor WorkspaceCodemapBindingEngine { job.isQueuedForAdmission = false job.isActiveBatch = false projectionJobs[rootEpoch] = job + cancelProjectionProgressPublication(rootEpoch: rootEpoch, resetLastPublication: true) + publishProjectionSnapshotUpdate(rootEpoch: rootEpoch) if recordSupersession { incrementCounter(\.projectionCoveragesSuperseded) emit(.projectionCoverageSuperseded, rootEpoch: rootEpoch, projectionPhase: .superseded) @@ -4095,6 +4387,7 @@ actor WorkspaceCodemapBindingEngine { job.progress = progress job.checkpoint = makeProjectionCheckpoint(job) projectionJobs[rootEpoch] = job + publishProjectionSnapshotUpdate(rootEpoch: rootEpoch) } private func updateProjectionPipelineScope( @@ -4380,26 +4673,34 @@ actor WorkspaceCodemapBindingEngine { ) { guard var job = projectionJobs[rootEpoch], job.id == jobID else { return } incrementCounter(\.projectionBudgetRejections) - emit( - .projectionBudget, - rootEpoch: rootEpoch, - numericValue: budget.attempted, - projectionPhase: .budgetLimited - ) job.phase = .budgetLimited job.progress = projectionProgress(job.progress, phase: .budgetLimited) + job.inBatchProgress = nil + job.pageStartProcessedCandidateBaseline = nil job.retry = nil job.budget = budget job.checkpoint = makeProjectionCheckpoint(job) projectionJobs[rootEpoch] = job + cancelProjectionProgressPublication(rootEpoch: rootEpoch, resetLastPublication: true) + publishProjectionSnapshotUpdate(rootEpoch: rootEpoch) + emit( + .projectionBudget, + rootEpoch: rootEpoch, + numericValue: budget.attempted, + projectionPhase: .budgetLimited + ) } private func supersedeProjectionJob(jobID: UUID, rootEpoch: WorkspaceCodemapRootEpoch) { guard var job = projectionJobs[rootEpoch], job.id == jobID else { return } job.phase = .superseded job.progress = projectionProgress(job.progress, phase: .superseded) + job.inBatchProgress = nil + job.pageStartProcessedCandidateBaseline = nil job.checkpoint = makeProjectionCheckpoint(job) projectionJobs[rootEpoch] = job + cancelProjectionProgressPublication(rootEpoch: rootEpoch, resetLastPublication: true) + publishProjectionSnapshotUpdate(rootEpoch: rootEpoch) incrementCounter(\.projectionCoveragesSuperseded) emit(.projectionCoverageSuperseded, rootEpoch: rootEpoch, projectionPhase: .superseded) } @@ -4440,6 +4741,8 @@ actor WorkspaceCodemapBindingEngine { terminalPhase: WorkspaceCodemapProjectionPreloadPhase ) -> Task? { guard var job = projectionJobs.removeValue(forKey: rootEpoch) else { return nil } + cancelProjectionProgressPublication(rootEpoch: rootEpoch, resetLastPublication: true) + publishProjectionSnapshotUpdate(rootEpoch: rootEpoch) let wasComplete = job.phase == .complete let wasActive = activeProjectionJobIDs.contains(job.id) job.phase = terminalPhase @@ -8102,5 +8405,23 @@ actor WorkspaceCodemapBindingEngine { publishedArtifactLookupMissReason: publishedArtifactLookupMissReason, invalidationReason: invalidationReason )) + if let rootEpoch, projectionStatusChanged(for: kind) { + let finish = if case .rootUnload = kind { true } else { false } + publishProjectionSnapshotUpdate(rootEpoch: rootEpoch, finish: finish) + } + } + + private func projectionStatusChanged(for kind: WorkspaceCodemapBindingEngineHookKind) -> Bool { + switch kind { + case .capabilityEligible, .capabilityTerminalUnavailable, .rootUnload, + .projectionPreloadScheduled, .projectionPreloadStarted, + .projectionBatchStarted, .projectionBatchCompleted, + .projectionSegmentPublished, .projectionCoverageComplete, + .projectionCoverageCancelled, .projectionCoverageSuperseded, + .projectionRetry, .projectionBudget: + true + default: + false + } } } diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift index c7c06f531..990c2e3a7 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift +++ b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift @@ -357,6 +357,13 @@ actor WorkspaceFileContextStore { let task: Task } + /// UI-status lower bound for coverage that remains valid while a path-level + /// invalidation replaces the current projection job. + private struct CodemapRootStatusCoverageBaseline { + var retainedCandidateCount: UInt64 + var invalidatedCandidateFileIDs: Set + } + private enum CodemapSetupDisposition { case ready case unavailable(WorkspaceCodemapArtifactDemandUnavailableReason) @@ -612,6 +619,7 @@ actor WorkspaceFileContextStore { ] = [:] var graphPublicationFlight: CodemapGraphPublicationFlight? var projectionRecoveryObserver: CodemapProjectionRecoveryObserver? + var projectionStatusTask: Task? var selectionGraph: CodemapSelectionGraphState? var graphSnapshotDirtyDuringPathInvalidation = false } @@ -627,6 +635,7 @@ actor WorkspaceFileContextStore { let projectionDemands: [CodemapProjectionDemandRecord] let graphPublicationTask: Task? let projectionRecoveryObserverTask: Task? + let projectionStatusTask: Task? let selectionGraph: WorkspaceCodemapSelectionGraph? let graphWorkerTask: Task? let preloadLaunchTask: Task? @@ -1375,6 +1384,22 @@ actor WorkspaceFileContextStore { codemapProjectionCatalogBuildHandler = handler } + func setCodemapProjectionPhaseForTesting( + rootID: UUID, + phase: WorkspaceCodemapProjectionPreloadPhase + ) { + guard let state = rootStatesByID[rootID] else { return } + let rootEpoch = WorkspaceCodemapRootEpoch(rootID: rootID, rootLifetimeID: state.lifetimeID) + codemapProjectionSnapshotsByRootEpoch[rootEpoch] = .pending( + phase: phase, + progress: .notStarted, + inBatchProgress: nil, + retry: nil, + budget: nil + ) + publishCodemapRootStatusesIfChanged() + } + func codemapProjectionPreloadStoreEventsForTesting( rootID: UUID? = nil ) -> [CodemapProjectionPreloadStoreEvent] { @@ -1682,7 +1707,7 @@ actor WorkspaceFileContextStore { terminalCount = proof.terminalCount lastSegmentSequence = proof.lastSegmentSequence coverageCompletedUptimeNanoseconds = completed - case let .pending(phase, progress, _, budget): + case let .pending(phase, progress, _, _, budget): projectionPhase = CodemapFullLoadDebugSupport.projectionPhaseName(phase) supportedCandidateCount = progress.counts.supportedCandidateCount processedCandidateCount = progress.counts.processedCandidateCount @@ -2725,6 +2750,19 @@ actor WorkspaceFileContextStore { WorkspaceCodemapRootEpoch: [UUID: CheckedContinuation] ] = [:] private var codemapProjectionPreloadReschedulePendingRootEpochs: Set = [] + private var codemapSuspendedRootEpochs: Set = [] + private var codemapResumeTransitionIDsByRootEpoch: [WorkspaceCodemapRootEpoch: UUID] = [:] + private var codemapProjectionSnapshotsByRootEpoch: [ + WorkspaceCodemapRootEpoch: WorkspaceCodemapCurrentProjectionSnapshot + ] = [:] + private var codemapRootStatusCoverageBaselinesByRootEpoch: [ + WorkspaceCodemapRootEpoch: CodemapRootStatusCoverageBaseline + ] = [:] + private var codemapRootStatusContinuations: [ + UUID: AsyncStream.Continuation + ] = [:] + private var codemapRootStatusRevision: UInt64 = 0 + private var lastPublishedCodemapRootStatuses: [WorkspaceCodemapRootStatusSnapshot] = [] private var codemapPathLocalCatalogMutationDepthByRootID: [UUID: Int] = [:] private var codemapAuthorityGenerationsByRootEpoch: [WorkspaceCodemapRootEpoch: UInt64] = [:] private var codemapProjectionInvalidationGenerationsByRootEpoch: [ @@ -2944,6 +2982,7 @@ actor WorkspaceFileContextStore { } session.graphPublicationFlight?.task?.cancel() session.projectionRecoveryObserver?.task?.cancel() + session.projectionStatusTask?.cancel() session.selectionGraph?.workerTask?.cancel() for bundle in session.bundlesByRequestID.values { bundle.close() @@ -2970,6 +3009,9 @@ actor WorkspaceFileContextStore { for continuation in codemapMarkerReadinessContinuations.values { continuation.finish() } + for continuation in codemapRootStatusContinuations.values { + continuation.finish() + } for continuation in fileSystemDeltaContinuations.values { continuation.finish() } @@ -5319,6 +5361,7 @@ actor WorkspaceFileContextStore { rootLifetimeID: pending.state.lifetimeID ) recordCodemapRootReadyForProjectionPreload(rootEpoch: rootEpoch) + publishCodemapRootStatusesIfChanged() scheduleCodemapProjectionPreloadAfterRootReady(rootEpoch: rootEpoch) } scheduleOrphanedSessionWorktreeResourceCleanup(previousResources) @@ -9753,6 +9796,204 @@ actor WorkspaceFileContextStore { codemapMarkerReadinessContinuations.removeValue(forKey: id) } + func codemapRootStatusUpdates() -> AsyncStream { + let id = UUID() + return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + codemapRootStatusContinuations[id] = continuation + continuation.yield(currentCodemapRootStatusUpdate()) + continuation.onTermination = { _ in + Task { await self.removeCodemapRootStatusContinuation(id) } + } + } + } + + func codemapRootStatusSnapshot(rootID: UUID) -> WorkspaceCodemapRootStatusSnapshot? { + guard let state = rootStatesByID[rootID] else { return nil } + return makeCodemapRootStatusSnapshot( + rootEpoch: WorkspaceCodemapRootEpoch(rootID: rootID, rootLifetimeID: state.lifetimeID) + ) + } + + func setCodemapGenerationSuspended( + rootID: UUID, + suspended: Bool + ) async -> WorkspaceCodemapRootSuspensionUpdateResult { + guard let state = rootStatesByID[rootID] else { return .rootUnavailable } + let rootEpoch = WorkspaceCodemapRootEpoch(rootID: rootID, rootLifetimeID: state.lifetimeID) + + if suspended { + let cancelledResume = codemapResumeTransitionIDsByRootEpoch.removeValue(forKey: rootEpoch) != nil + let inserted = codemapSuspendedRootEpochs.insert(rootEpoch).inserted + guard inserted || cancelledResume else { return .unchanged } + codemapProjectionPreloadReschedulePendingRootEpochs.remove(rootEpoch) + publishCodemapRootStatusesIfChanged() + if inserted { + let engine = codemapSessionsByRootEpoch[rootEpoch]?.engine + _ = detachCodemapSession(rootEpoch: rootEpoch) + if let engine { + await engine.cancelProjectionPreload(rootEpoch: rootEpoch) + } + } + return .changed + } + + guard codemapSuspendedRootEpochs.contains(rootEpoch), + codemapResumeTransitionIDsByRootEpoch[rootEpoch] == nil + else { return .unchanged } + let resumeID = UUID() + codemapResumeTransitionIDsByRootEpoch[rootEpoch] = resumeID + if let cleanup = codemapCleanupFlightsByRootID[rootID] { + await cleanup.task.value + } + guard rootStatesByID[rootID]?.lifetimeID == rootEpoch.rootLifetimeID else { + if codemapResumeTransitionIDsByRootEpoch[rootEpoch] == resumeID { + codemapResumeTransitionIDsByRootEpoch.removeValue(forKey: rootEpoch) + } + return .rootUnavailable + } + guard codemapResumeTransitionIDsByRootEpoch[rootEpoch] == resumeID, + codemapSuspendedRootEpochs.remove(rootEpoch) != nil + else { return .unchanged } + codemapResumeTransitionIDsByRootEpoch.removeValue(forKey: rootEpoch) + scheduleCodemapProjectionPreloadAfterRootReady(rootEpoch: rootEpoch) + publishCodemapRootStatusesIfChanged() + return .changed + } + + private func removeCodemapRootStatusContinuation(_ id: UUID) { + codemapRootStatusContinuations.removeValue(forKey: id) + } + + private func codemapGenerationIsSuspended(rootEpoch: WorkspaceCodemapRootEpoch) -> Bool { + codemapSuspendedRootEpochs.contains(rootEpoch) + } + + func currentCodemapRootStatusUpdate() -> WorkspaceCodemapRootStatusUpdate { + WorkspaceCodemapRootStatusUpdate( + revision: codemapRootStatusRevision, + roots: currentCodemapRootStatusSnapshots() + ) + } + + private func currentCodemapRootStatusSnapshots() -> [WorkspaceCodemapRootStatusSnapshot] { + rootLoadOrder.compactMap { rootID in + guard let state = rootStatesByID[rootID] else { return nil } + return makeCodemapRootStatusSnapshot( + rootEpoch: WorkspaceCodemapRootEpoch(rootID: rootID, rootLifetimeID: state.lifetimeID) + ) + } + } + + private func makeCodemapRootStatusSnapshot( + rootEpoch: WorkspaceCodemapRootEpoch + ) -> WorkspaceCodemapRootStatusSnapshot { + if codemapGenerationIsSuspended(rootEpoch: rootEpoch) { + return WorkspaceCodemapRootStatusSnapshot( + rootEpoch: rootEpoch, + state: .paused, + processedCandidateCount: 0, + totalCandidateCount: nil + ) + } + + let knownTotalCandidateCount = codemapKnownTotalCandidateCount(rootEpoch: rootEpoch) + if let projection = codemapProjectionSnapshotsByRootEpoch[rootEpoch] { + switch projection { + case let .authoritativeComplete(proof, _): + return WorkspaceCodemapRootStatusSnapshot( + rootEpoch: rootEpoch, + state: .ready, + processedCandidateCount: proof.counts.processedCandidateCount, + totalCandidateCount: proof.catalogCompletion.supportedCandidateCount + ) + case let .pending(phase, progress, inBatchProgress, _, _): + let state: WorkspaceCodemapRootStatusState = switch phase { + case .suspendedBusy: + .generating + case .budgetLimited: + .waiting + case .complete: + .ready + case .cancelled, .superseded: + .idle + default: + .generating + } + let totalCandidateCount = progress.catalogCompletion?.supportedCandidateCount + ?? knownTotalCandidateCount + let processedCandidateCount = normalizedCodemapRootStatusCandidateCount( + max( + progress.counts.processedCandidateCount, + codemapRootStatusCoverageBaselinesByRootEpoch[rootEpoch]? + .retainedCandidateCount ?? 0 + ), + totalCandidateCount: totalCandidateCount + ) + return WorkspaceCodemapRootStatusSnapshot( + rootEpoch: rootEpoch, + state: state, + processedCandidateCount: processedCandidateCount, + locallyResolvedCandidateCountThroughRoot: inBatchProgress? + .locallyResolvedCandidateCountThroughRoot, + totalCandidateCount: totalCandidateCount + ) + case .unavailable, .nonCurrent: + break + } + } + + let state: WorkspaceCodemapRootStatusState = switch codemapProjectionPreloadLaunchesByRootEpoch[rootEpoch]?.phase { + case .eligibilityQueued, .setupJoining, .engineScheduling: + .preparing + case .handedOff: + .generating + case .transientRetry: + .waiting + case .terminalNonGit: + .unavailable + case .notScheduled, .cancelled, .superseded, nil: + .idle + } + let processedCandidateCount = normalizedCodemapRootStatusCandidateCount( + codemapRootStatusCoverageBaselinesByRootEpoch[rootEpoch]?.retainedCandidateCount ?? 0, + totalCandidateCount: knownTotalCandidateCount + ) + return WorkspaceCodemapRootStatusSnapshot( + rootEpoch: rootEpoch, + state: state, + processedCandidateCount: processedCandidateCount, + totalCandidateCount: knownTotalCandidateCount + ) + } + + private func normalizedCodemapRootStatusCandidateCount( + _ candidateCount: UInt64, + totalCandidateCount: UInt64? + ) -> UInt64 { + guard let totalCandidateCount else { return candidateCount } + return min(candidateCount, totalCandidateCount) + } + + private func codemapKnownTotalCandidateCount( + rootEpoch: WorkspaceCodemapRootEpoch + ) -> UInt64? { + guard let authority = currentCodemapAuthority(rootEpoch: rootEpoch), + let shard = codemapProjectionCatalogShardAndToken(authority: authority)?.shard + else { return nil } + return UInt64(exactly: shard.projectionFiles.count) + } + + private func publishCodemapRootStatusesIfChanged() { + let roots = currentCodemapRootStatusSnapshots() + guard roots != lastPublishedCodemapRootStatuses else { return } + lastPublishedCodemapRootStatuses = roots + codemapRootStatusRevision &+= 1 + let update = WorkspaceCodemapRootStatusUpdate(revision: codemapRootStatusRevision, roots: roots) + for continuation in codemapRootStatusContinuations.values { + continuation.yield(update) + } + } + @discardableResult func loadRoot( path: String, @@ -10173,6 +10414,7 @@ actor WorkspaceFileContextStore { rootLifetimeID: state.lifetimeID ) recordCodemapRootReadyForProjectionPreload(rootEpoch: rootEpoch) + publishCodemapRootStatusesIfChanged() scheduleCodemapProjectionPreloadAfterRootReady(rootEpoch: rootEpoch) #if DEBUG WorkspaceRestorePerfLog.event( @@ -10407,6 +10649,11 @@ actor WorkspaceFileContextStore { continuation.resume() } codemapProjectionPreloadReschedulePendingRootEpochs.remove(rootEpoch) + codemapSuspendedRootEpochs.remove(rootEpoch) + codemapResumeTransitionIDsByRootEpoch.removeValue(forKey: rootEpoch) + codemapProjectionSnapshotsByRootEpoch.removeValue(forKey: rootEpoch) + codemapRootStatusCoverageBaselinesByRootEpoch.removeValue(forKey: rootEpoch) + publishCodemapRootStatusesIfChanged() if let cleanup = detachCodemapSession( rootEpoch: rootEpoch, invalidationCommands: [.unload], @@ -13354,7 +13601,8 @@ actor WorkspaceFileContextStore { private func currentCodemapAuthority( rootEpoch: WorkspaceCodemapRootEpoch ) -> CodemapRootAuthority? { - guard let state = rootStatesByID[rootEpoch.rootID], + guard !codemapGenerationIsSuspended(rootEpoch: rootEpoch), + let state = rootStatesByID[rootEpoch.rootID], state.lifetimeID == rootEpoch.rootLifetimeID else { return nil } if let session = codemapSessionsByRootEpoch[rootEpoch], @@ -13434,7 +13682,8 @@ actor WorkspaceFileContextStore { #if DEBUG guard codemapProjectionPreloadLaunchPolicyForTesting == .enabled else { return } #endif - guard codemapCleanupFlightsByRootID[rootEpoch.rootID] == nil, + guard !codemapGenerationIsSuspended(rootEpoch: rootEpoch), + codemapCleanupFlightsByRootID[rootEpoch.rootID] == nil, codemapRootMutationFenceTokensByRootEpoch[rootEpoch] == nil, let authority = currentCodemapAuthority(rootEpoch: rootEpoch), codemapPreflightAuthorityIsCurrent(authority) @@ -13477,6 +13726,7 @@ actor WorkspaceFileContextStore { } launch.task = task codemapProjectionPreloadLaunchesByRootEpoch[rootEpoch] = launch + publishCodemapRootStatusesIfChanged() } private func runCodemapProjectionPreloadLaunch( @@ -13810,7 +14060,9 @@ actor WorkspaceFileContextStore { private func ensureCodemapSetupTask( authority: CodemapRootAuthority ) -> Task? { - guard codemapPreflightAuthorityIsCurrent(authority) else { return nil } + guard !codemapGenerationIsSuspended(rootEpoch: authority.rootEpoch), + codemapPreflightAuthorityIsCurrent(authority) + else { return nil } codemapCompletedEligibilityByRootEpoch.removeValue(forKey: authority.rootEpoch) if let existing = codemapSessionsByRootEpoch[authority.rootEpoch] { guard existing.authority == authority else { return nil } @@ -13843,6 +14095,7 @@ actor WorkspaceFileContextStore { guard let launch = codemapProjectionPreloadLaunchesByRootEpoch[authority.rootEpoch], launch.id == launchID, launch.authority == authority, + !codemapGenerationIsSuspended(rootEpoch: authority.rootEpoch), codemapPreflightAuthorityIsCurrent(authority) else { return false } return true @@ -13859,6 +14112,7 @@ actor WorkspaceFileContextStore { else { return } launch.phase = phase codemapProjectionPreloadLaunchesByRootEpoch[authority.rootEpoch] = launch + publishCodemapRootStatusesIfChanged() } private func finishCodemapProjectionPreloadLaunch( @@ -13872,6 +14126,7 @@ actor WorkspaceFileContextStore { else { return } launch.phase = phase codemapProjectionPreloadLaunchesByRootEpoch[authority.rootEpoch] = launch + publishCodemapRootStatusesIfChanged() if phase == .cancelled || phase == .superseded { recordCodemapProjectionPreloadStoreEvent( phase == .cancelled ? .cancelled : .superseded, @@ -13892,7 +14147,8 @@ actor WorkspaceFileContextStore { launchID: UUID, authority: CodemapRootAuthority ) { - guard let launch = codemapProjectionPreloadLaunchesByRootEpoch[authority.rootEpoch], + guard !codemapGenerationIsSuspended(rootEpoch: authority.rootEpoch), + let launch = codemapProjectionPreloadLaunchesByRootEpoch[authority.rootEpoch], launch.id == launchID, launch.authority == authority, launch.phase == .transientRetry, @@ -13949,6 +14205,7 @@ actor WorkspaceFileContextStore { else { return } codemapProjectionPreloadRetriesByRootEpoch.removeValue(forKey: authority.rootEpoch) guard !Task.isCancelled, + !codemapGenerationIsSuspended(rootEpoch: authority.rootEpoch), codemapProjectionPreloadRetryPolicy.nowNanoseconds() >= deadlineNanoseconds, codemapPreflightAuthorityIsCurrent(authority) else { return } @@ -14444,6 +14701,9 @@ actor WorkspaceFileContextStore { rootID: file.rootID, rootLifetimeID: state.lifetimeID ) + if codemapGenerationIsSuspended(rootEpoch: rootEpoch) { + return .init(result: .unavailable(.cancelled), ownership: .notAcquired) + } if codemapCleanupFlightsByRootID[file.rootID] != nil || codemapRootMutationFenceTokensByRootEpoch[rootEpoch] != nil || codemapPathIsFenced(rootEpoch: rootEpoch, relativePath: file.standardizedRelativePath) @@ -14663,6 +14923,9 @@ actor WorkspaceFileContextStore { return .unavailable(reason: .generationMismatch, retryAfterMilliseconds: nil) } let rootEpoch = first.rootEpoch + guard !codemapGenerationIsSuspended(rootEpoch: rootEpoch) else { + return .unavailable(reason: .capabilityUnavailable, retryAfterMilliseconds: nil) + } var uniqueFileIDs: [UUID] = [] var seenFileIDs = Set() for ticket in sourceTickets { @@ -16457,9 +16720,62 @@ actor WorkspaceFileContextStore { .unavailable(.registrationFailed) } publishCodemapSetupDisposition(disposition, authority: authority) + if case .ready = disposition { + startCodemapProjectionStatusObserver(authority: authority, engine: engine) + } return disposition } + private func startCodemapProjectionStatusObserver( + authority: CodemapRootAuthority, + engine: WorkspaceCodemapBindingEngine + ) { + guard var session = codemapSessionsByRootEpoch[authority.rootEpoch], + session.authority == authority, + session.engine === engine + else { return } + session.projectionStatusTask?.cancel() + let task = Task { [weak self] in + let stream = await engine.projectionSnapshotUpdates(rootEpoch: authority.rootEpoch) + for await snapshot in stream { + guard !Task.isCancelled else { return } + await self?.acceptCodemapProjectionStatusSnapshot( + snapshot, + authority: authority, + engine: engine + ) + } + } + session.projectionStatusTask = task + codemapSessionsByRootEpoch[authority.rootEpoch] = session + } + + private func acceptCodemapProjectionStatusSnapshot( + _ snapshot: WorkspaceCodemapCurrentProjectionSnapshot, + authority: CodemapRootAuthority, + engine: WorkspaceCodemapBindingEngine + ) { + guard let session = codemapSessionsByRootEpoch[authority.rootEpoch], + session.authority == authority, + session.engine === engine, + codemapAuthorityIsCurrent(authority) + else { return } + if case let .authoritativeComplete(proof, _) = snapshot, + let current = codemapProjectionCatalogShardAndToken(authority: authority), + let candidateCount = UInt64(exactly: current.shard.projectionFiles.count), + proof.counts.processedCandidateCount == candidateCount, + proof.catalogCompletion.supportedCandidateCount == candidateCount + { + codemapRootStatusCoverageBaselinesByRootEpoch[authority.rootEpoch] = + CodemapRootStatusCoverageBaseline( + retainedCandidateCount: candidateCount, + invalidatedCandidateFileIDs: [] + ) + } + codemapProjectionSnapshotsByRootEpoch[authority.rootEpoch] = snapshot + publishCodemapRootStatusesIfChanged() + } + private func performCodemapDemand( ticket: WorkspaceCodemapArtifactDemandTicket, priority: CodeMapArtifactBuildPriority @@ -17750,6 +18066,7 @@ actor WorkspaceFileContextStore { capability: .recordsOnly ) registerPublishedRootCatalogShard(shard, kind: .authoritative) + publishCodemapRootStatusesIfChanged() return .ready } @@ -18441,6 +18758,10 @@ actor WorkspaceFileContextStore { guard !paths.isEmpty, let state = rootStatesByID[rootID] else { return (nil, nil) } let rootEpoch = WorkspaceCodemapRootEpoch(rootID: rootID, rootLifetimeID: state.lifetimeID) + retainCodemapRootStatusCoverageAcrossPathInvalidation( + rootEpoch: rootEpoch, + standardizedRelativePaths: paths + ) advanceCodemapProjectionInvalidationGeneration(rootEpoch: rootEpoch) _ = cancelCodemapProjectionPreloadLaunchForInvalidation(rootEpoch: rootEpoch) @@ -18627,6 +18948,29 @@ actor WorkspaceFileContextStore { return (token, task) } + private func retainCodemapRootStatusCoverageAcrossPathInvalidation( + rootEpoch: WorkspaceCodemapRootEpoch, + standardizedRelativePaths: Set + ) { + guard var baseline = codemapRootStatusCoverageBaselinesByRootEpoch[rootEpoch], + let state = rootStatesByID[rootEpoch.rootID], + state.lifetimeID == rootEpoch.rootLifetimeID, + let authority = currentCodemapAuthority(rootEpoch: rootEpoch), + let shard = codemapProjectionCatalogShardAndToken(authority: authority)?.shard + else { return } + + for path in standardizedRelativePaths { + guard let fileID = state.fileIDsByRelativePath[path], + shard.projectionFileIndexByID[fileID] != nil, + baseline.invalidatedCandidateFileIDs.insert(fileID).inserted + else { continue } + baseline.retainedCandidateCount = baseline.retainedCandidateCount > 0 + ? baseline.retainedCandidateCount - 1 + : 0 + } + codemapRootStatusCoverageBaselinesByRootEpoch[rootEpoch] = baseline + } + private func performCodemapPathInvalidation( flightID: UUID, authority: CodemapRootAuthority, @@ -18862,6 +19206,9 @@ actor WorkspaceFileContextStore { forKey: rootEpoch ) let session = codemapSessionsByRootEpoch.removeValue(forKey: rootEpoch) + codemapProjectionSnapshotsByRootEpoch.removeValue(forKey: rootEpoch) + codemapRootStatusCoverageBaselinesByRootEpoch.removeValue(forKey: rootEpoch) + publishCodemapRootStatusesIfChanged() if let session, !session.markerReadinessByFileID.isEmpty { let changes = session.markerReadinessByFileID.values.map { WorkspaceCodemapMarkerReadinessChange( @@ -18911,6 +19258,7 @@ actor WorkspaceFileContextStore { session?.setupTask?.cancel() session?.graphPublicationFlight?.task?.cancel() session?.projectionRecoveryObserver?.task?.cancel() + session?.projectionStatusTask?.cancel() session?.selectionGraph?.workerTask?.cancel() let demandRecords = session.map { Array($0.demandsByFileID.values) } ?? [] for record in demandRecords { @@ -18932,6 +19280,7 @@ actor WorkspaceFileContextStore { projectionDemands: projectionDemands, graphPublicationTask: session?.graphPublicationFlight?.task, projectionRecoveryObserverTask: session?.projectionRecoveryObserver?.task, + projectionStatusTask: session?.projectionStatusTask, selectionGraph: session?.selectionGraph?.graph, graphWorkerTask: session?.selectionGraph?.workerTask, preloadLaunchTask: launch?.task, @@ -18963,6 +19312,9 @@ actor WorkspaceFileContextStore { if let projectionRecoveryObserverTask = detached.projectionRecoveryObserverTask { await projectionRecoveryObserverTask.value } + if let projectionStatusTask = detached.projectionStatusTask { + await projectionStatusTask.value + } if let selectionGraph = detached.selectionGraph { _ = await selectionGraph.invalidateCurrentness( rootEpoch: detached.authority.rootEpoch, @@ -19181,6 +19533,10 @@ actor WorkspaceFileContextStore { private func schedulePendingCodemapProjectionPreloadIfFullyUnfenced( rootEpoch: WorkspaceCodemapRootEpoch ) { + if codemapGenerationIsSuspended(rootEpoch: rootEpoch) { + codemapProjectionPreloadReschedulePendingRootEpochs.remove(rootEpoch) + return + } guard codemapProjectionPreloadReschedulePendingRootEpochs.contains(rootEpoch), codemapRootMutationFenceTokensByRootEpoch[rootEpoch] == nil, codemapPathInvalidationFlightsByRootEpoch[rootEpoch] == nil, diff --git a/Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift b/Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift index 7181680f6..8b8077187 100644 --- a/Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift +++ b/Tests/RepoPromptTests/AgentMode/AgentWorkspaceRootsSidebarStoreTests.swift @@ -63,6 +63,94 @@ final class AgentWorkspaceRootsSidebarStoreTests: XCTestCase { } } + func testRowsMapCompactCodemapProgressAndUnavailableState() { + let rootA = makeProjection(name: "A", path: "/tmp/A") + let rootB = makeProjection(name: "B", path: "/tmp/B") + let snapshots = [ + rootA.id: WorkspaceCodemapRootStatusSnapshot( + rootEpoch: WorkspaceCodemapRootEpoch(rootID: rootA.id, rootLifetimeID: UUID()), + state: .generating, + processedCandidateCount: 7, + totalCandidateCount: 12 + ), + rootB.id: WorkspaceCodemapRootStatusSnapshot( + rootEpoch: WorkspaceCodemapRootEpoch(rootID: rootB.id, rootLifetimeID: UUID()), + state: .unavailable, + processedCandidateCount: 0, + totalCandidateCount: nil + ) + ] + + let rows = AgentWorkspaceRootsSidebarStore.rows( + from: [rootA, rootB], + codemapStatusLookup: { snapshots[$0] } + ) + + XCTAssertEqual(rows[0].codemap.state, .mapping) + XCTAssertEqual(rows[0].codemap.percentageText, "58%") + XCTAssertTrue(rows[0].codemap.canToggle) + XCTAssertEqual(rows[1].codemap.state, .unavailable) + XCTAssertNil(rows[1].codemap.progressFraction) + XCTAssertFalse(rows[1].codemap.canToggle) + + let localOnly = AgentWorkspaceCodemapPresentation.make(WorkspaceCodemapRootStatusSnapshot( + rootEpoch: WorkspaceCodemapRootEpoch(rootID: rootA.id, rootLifetimeID: UUID()), + state: .generating, + processedCandidateCount: 0, + locallyResolvedCandidateCountThroughRoot: 17, + totalCandidateCount: 64 + )) + XCTAssertEqual(localOnly.processedCandidateCount, 0) + XCTAssertEqual(localOnly.displayProcessedCandidateCount, 17) + XCTAssertEqual(localOnly.percentageText, "26%") + XCTAssertEqual(localOnly.progressFraction ?? 0, 17.0 / 64.0, accuracy: 0.000_001) + XCTAssertTrue(localOnly.tooltip.contains("processed for mapping")) + + let belowOnePercent = AgentWorkspaceCodemapPresentation.make(WorkspaceCodemapRootStatusSnapshot( + rootEpoch: WorkspaceCodemapRootEpoch(rootID: rootA.id, rootLifetimeID: UUID()), + state: .generating, + processedCandidateCount: 1, + totalCandidateCount: 1000 + )) + XCTAssertEqual(belowOnePercent.progressFraction ?? 0, 0.001, accuracy: 0.000_001) + XCTAssertEqual(belowOnePercent.percentageText, "<1%") + XCTAssertEqual(belowOnePercent.statusText, "Mapping <1%") + + let acceptedAndLocal = AgentWorkspaceCodemapPresentation.make(WorkspaceCodemapRootStatusSnapshot( + rootEpoch: WorkspaceCodemapRootEpoch(rootID: rootA.id, rootLifetimeID: UUID()), + state: .generating, + processedCandidateCount: 12, + locallyResolvedCandidateCountThroughRoot: 17, + totalCandidateCount: 64 + )) + XCTAssertEqual(acceptedAndLocal.displayProcessedCandidateCount, 17) + + let locallyResolvedAll = AgentWorkspaceCodemapPresentation.make(WorkspaceCodemapRootStatusSnapshot( + rootEpoch: WorkspaceCodemapRootEpoch(rootID: rootA.id, rootLifetimeID: UUID()), + state: .generating, + processedCandidateCount: 0, + locallyResolvedCandidateCountThroughRoot: 64, + totalCandidateCount: 64 + )) + XCTAssertEqual(locallyResolvedAll.progressFraction, 0.99) + XCTAssertEqual(locallyResolvedAll.percentageText, "99%") + + let nearlyComplete = AgentWorkspaceCodemapPresentation.make(WorkspaceCodemapRootStatusSnapshot( + rootEpoch: WorkspaceCodemapRootEpoch(rootID: rootA.id, rootLifetimeID: UUID()), + state: .generating, + processedCandidateCount: 199, + totalCandidateCount: 200 + )) + XCTAssertEqual(nearlyComplete.percentageText, "99%") + let complete = AgentWorkspaceCodemapPresentation.make(WorkspaceCodemapRootStatusSnapshot( + rootEpoch: WorkspaceCodemapRootEpoch(rootID: rootA.id, rootLifetimeID: UUID()), + state: .ready, + processedCandidateCount: 200, + totalCandidateCount: 200 + )) + XCTAssertEqual(complete.percentageText, "100%") + } + func testRowsAttachGitContextByStandardizedRootPathWithoutChangingOrder() { let rootA = makeProjection(name: "A", path: "/tmp/A") let rootB = makeProjection(name: "B", path: "/tmp/B") @@ -108,6 +196,77 @@ final class AgentWorkspaceRootsSidebarStoreTests: XCTestCase { XCTAssertEqual(store.rootRows.map(\.id), [rootB.id]) } + func testCodemapStatusNotificationsCoalesceRootRowResnapshots() async { + let root = makeProjection(name: "A", path: "/tmp/A") + let manager = makeWorkspaceManager() + let codemapChanges = PassthroughSubject() + var lookupCount = 0 + let snapshot = WorkspaceCodemapRootStatusSnapshot( + rootEpoch: WorkspaceCodemapRootEpoch(rootID: root.id, rootLifetimeID: UUID()), + state: .generating, + processedCandidateCount: 1, + totalCandidateCount: 2 + ) + let store = AgentWorkspaceRootsSidebarStore( + rootProjections: { [root] }, + rootChanges: Empty().eraseToAnyPublisher(), + codemapStatusLookup: { _ in + lookupCount += 1 + return snapshot + }, + codemapStatusChanges: codemapChanges.eraseToAnyPublisher(), + workspaceManager: manager, + windowID: -1 + ) + XCTAssertEqual(lookupCount, 1) + + codemapChanges.send(()) + codemapChanges.send(()) + await waitUntil { lookupCount >= 2 } + + XCTAssertEqual(lookupCount, 2) + XCTAssertEqual(store.rootRows.first?.codemap.displayProcessedCandidateCount, 1) + } + + func testCodemapToggleResnapshotsAuthoritativeStateBeforeClearingPending() async { + let root = makeProjection(name: "A", path: "/tmp/A") + let manager = makeWorkspaceManager() + var snapshot = WorkspaceCodemapRootStatusSnapshot( + rootEpoch: WorkspaceCodemapRootEpoch(rootID: root.id, rootLifetimeID: UUID()), + state: .paused, + processedCandidateCount: 0, + totalCandidateCount: nil + ) + var actions: [(UUID, Bool)] = [] + let store = AgentWorkspaceRootsSidebarStore( + rootProjections: { [root] }, + rootChanges: Empty().eraseToAnyPublisher(), + codemapStatusLookup: { _ in snapshot }, + setCodemapSuspended: { rootID, suspended in + actions.append((rootID, suspended)) + snapshot = WorkspaceCodemapRootStatusSnapshot( + rootEpoch: snapshot.rootEpoch, + state: suspended ? .paused : .generating, + processedCandidateCount: 0, + totalCandidateCount: nil + ) + }, + workspaceManager: manager, + windowID: -1 + ) + + await store.toggleCodemapGeneration(rowID: root.id) + XCTAssertEqual(actions.map(\.0), [root.id]) + XCTAssertEqual(actions.map(\.1), [false]) + XCTAssertFalse(store.rootRows[0].codemap.isPaused) + + await store.toggleCodemapGeneration(rowID: root.id) + + XCTAssertEqual(actions.map(\.1), [false, true]) + XCTAssertTrue(store.rootRows[0].codemap.isPaused) + XCTAssertTrue(store.codemapActionRootIDs.isEmpty) + } + // MARK: - Root context actions func testRootContextActionCopiesRawBranchInsteadOfDisplayText() { @@ -213,6 +372,7 @@ final class AgentWorkspaceRootsSidebarStoreTests: XCTestCase { XCTAssertEqual(enriched.canMoveUp, base.canMoveUp) XCTAssertEqual(enriched.canMoveDown, base.canMoveDown) XCTAssertEqual(enriched.gitContext, base.gitContext) + XCTAssertEqual(enriched.codemap, base.codemap) } func testWithWorktreePreservesGitContext() { diff --git a/Tests/RepoPromptTests/Helpers/CodemapBindingEngineTestSupport.swift b/Tests/RepoPromptTests/Helpers/CodemapBindingEngineTestSupport.swift index b45229a7e..1d078ce88 100644 --- a/Tests/RepoPromptTests/Helpers/CodemapBindingEngineTestSupport.swift +++ b/Tests/RepoPromptTests/Helpers/CodemapBindingEngineTestSupport.swift @@ -948,6 +948,10 @@ actor EngineMultiEntryGate { } } + func releaseOne() { + state.releaseOne() + } + func releaseAll() { state.releaseAll() } @@ -1033,6 +1037,14 @@ private final class EngineMultiEntryGateState: @unchecked Sendable { return count >= expectedCount } + func releaseOne() { + let continuation = lock.withLock { () -> CheckedContinuation? in + guard let id = continuations.keys.first else { return nil } + return continuations.removeValue(forKey: id) + } + continuation?.resume() + } + func releaseAll() { let pending = lock.withLock { () -> [CheckedContinuation] in released = true @@ -1402,6 +1414,48 @@ actor EngineProjectionRecorder { } } +actor EngineProjectionPartialPageRetryPublisher { + private let rereadGate: EngineAsyncGate + private var acceptedProgress = WorkspaceCodemapProjectionProgress.notStarted + private var segmentPublicationCount = 0 + private(set) var snapshots: [WorkspaceCodemapProjectionSnapshot] = [] + + init(rereadGate: EngineAsyncGate) { + self.rereadGate = rereadGate + } + + func publish( + _ snapshot: WorkspaceCodemapProjectionSnapshot + ) async -> WorkspaceCodemapProjectionSnapshotDisposition { + snapshots.append(snapshot) + switch snapshot { + case let .segment(segment): + segmentPublicationCount += 1 + switch segmentPublicationCount { + case 1: + acceptedProgress = segment.progress + return .accepted(acceptedProgress) + case 2: + return .busy(retryAfterMilliseconds: nil) + case 3: + await rereadGate.enterAndWait() + return .exactDuplicate(acceptedProgress) + default: + acceptedProgress = segment.progress + return .accepted(acceptedProgress) + } + case let .seal(proof): + guard case let .success(completed) = acceptedProgress.advancing( + to: .complete, + by: .zero, + catalogCompletion: proof.catalogCompletion + ) else { return .unavailable(.invalidCompletenessProof) } + acceptedProgress = completed + return .accepted(completed) + } + } +} + actor EngineProjectionGenerationRacePublisher { private let gate: EngineAsyncGate private let recorder: EngineProjectionRecorder diff --git a/Tests/RepoPromptTests/Helpers/CodemapSeamTestSupport.swift b/Tests/RepoPromptTests/Helpers/CodemapSeamTestSupport.swift index bb0595c78..b94c6c11e 100644 --- a/Tests/RepoPromptTests/Helpers/CodemapSeamTestSupport.swift +++ b/Tests/RepoPromptTests/Helpers/CodemapSeamTestSupport.swift @@ -946,6 +946,8 @@ final class CodemapStoreFixture: @unchecked Sendable { projectionAuthority: ProjectionAuthority = .engine, resolutionGate: CodemapResolutionGate? = nil, syntheticGraphArtifacts: Bool = false, + artifactBuilder: CodeMapArtifactBuilderClient? = nil, + artifactCoordinatorPolicy: CodeMapArtifactBuildCoordinatorPolicy = .default, bindingEnginePolicy: WorkspaceCodemapBindingEnginePolicy = .default, manifestStoreFaultAction: @escaping @Sendable ( CodeMapRootManifestStoreFaultPoint @@ -959,7 +961,7 @@ final class CodemapStoreFixture: @unchecked Sendable { let manifestReadCount = manifestReadCount let buildCount = buildCount let buildPriorities = buildPriorities - let defaultBuilder = CodeMapArtifactBuilderClient() + let defaultBuilder = artifactBuilder ?? CodeMapArtifactBuilderClient() let runtimeTracker = CodemapRuntimeTracker() let freshRuntimeFactory: @Sendable () throws -> CodeMapArtifactRuntime = { runtimeFactoryCount.increment() @@ -985,6 +987,7 @@ final class CodemapStoreFixture: @unchecked Sendable { } return try await defaultBuilder.execute(input, ownerID, priority) }), + coordinatorPolicy: artifactCoordinatorPolicy, bindingIntegrationRegistry: registry, bindingEngineFactory: { runtime in engineFactoryCount.increment() diff --git a/Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEngineProjectionTests.swift b/Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEngineProjectionTests.swift index 03de847c9..31f29602e 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEngineProjectionTests.swift +++ b/Tests/RepoPromptTests/WorkspaceContext/CodemapBindingEngineProjectionTests.swift @@ -79,6 +79,17 @@ final class CodemapBindingEngineProjectionTests: CodemapBindingEngineTestCase { rootEpoch: fixture.rootEpoch ) XCTAssertEqual(preScheduleProjection, .unavailable(reason: .jobNotScheduled)) + let statusStream = await fixture.engine.projectionSnapshotUpdates(rootEpoch: fixture.rootEpoch) + let observedStatusTask = Task { () -> [WorkspaceCodemapCurrentProjectionSnapshot] in + var snapshots: [WorkspaceCodemapCurrentProjectionSnapshot] = [] + for await snapshot in statusStream { + snapshots.append(snapshot) + if case .authoritativeComplete = snapshot { + return snapshots + } + } + return snapshots + } let firstSchedule = await fixture.engine.scheduleProjectionPreload(rootEpoch: fixture.rootEpoch) let duplicateSchedule = await fixture.engine.scheduleProjectionPreload(rootEpoch: fixture.rootEpoch) @@ -102,6 +113,16 @@ final class CodemapBindingEngineProjectionTests: CodemapBindingEngineTestCase { XCTAssertEqual(proof.generation.rootEpoch, fixture.rootEpoch) XCTAssertEqual(proof.counts.supportedCandidateCount, 0) XCTAssertGreaterThan(completedUptimeNanoseconds, 0) + let observedStatuses = await observedStatusTask.value + XCTAssertTrue(observedStatuses.contains { snapshot in + if case .pending = snapshot { true } else { false } + }) + XCTAssertTrue(observedStatuses.contains { snapshot in + if case .authoritativeComplete = snapshot { true } else { false } + }) + for (previous, current) in zip(observedStatuses, observedStatuses.dropFirst()) { + XCTAssertNotEqual(previous, current) + } let snapshots = await recorder.snapshots XCTAssertEqual(snapshots.count, 1) guard case .seal = snapshots.first else { @@ -109,6 +130,240 @@ final class CodemapBindingEngineProjectionTests: CodemapBindingEngineTestCase { } } + func testProjectionSnapshotUpdatesExposeResolvedCandidatesBeforeSingleBatchAcceptance() async throws { + let candidateCount = 5 + let paths = (0 ..< candidateCount).map { "Sources/Progress\($0).swift" } + let repository = try makeRepositoryFixture(name: #function) + let root = try repository.makeRepository( + named: "repository", + files: Dictionary(uniqueKeysWithValues: paths.map { path in + ( + path, + SwiftFixtureSource.emptyStruct( + URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent + ) + ) + }) + ) + let candidateGate = EngineMultiEntryGate() + addTeardownBlock { await candidateGate.releaseAll() } + let runtime = try CodeMapArtifactRuntime( + rootURL: makeSecureDirectory(in: repository.sandbox, named: "artifacts"), + builder: CodeMapArtifactBuilderClient(build: { _, _, _ in + await candidateGate.enter() + return .readyNoSymbols + }), + coordinatorPolicy: CodeMapArtifactBuildCoordinatorPolicy( + maximumConcurrentBuildCount: candidateCount + ) + ) + let recorder = EngineProjectionRecorder() + let fixture = try await makeEngineFixture( + root: root, + runtime: runtime, + policy: WorkspaceCodemapBindingEnginePolicy( + maximumConcurrentMaterializationCountPerOwner: candidateCount + ), + projectionCatalogFactory: { rootEpoch, fileIDs in + let candidates = paths.map { path in + WorkspaceCodemapProjectionCatalogCandidate( + identity: WorkspaceCodemapArtifactBindingIdentity( + rootID: rootEpoch.rootID, + rootLifetimeID: rootEpoch.rootLifetimeID, + fileID: fileIDs.id(for: path), + standardizedRootPath: root.path, + standardizedRelativePath: path, + standardizedFullPath: root.appendingPathComponent(path).path + )!, + language: .swift, + requestGeneration: 1, + pathGeneration: 1 + ) + } + return EngineProjectionCatalogStub( + rootEpoch: rootEpoch, + entries: candidates, + recorder: recorder + ).client + } + ) + guard case .registered = await fixture.engine.registerRoot(fixture.registration) else { + return XCTFail("Expected eligible registration.") + } + let statusStream = await fixture.engine.projectionSnapshotUpdates(rootEpoch: fixture.rootEpoch) + let intermediateProgressObserved = expectation( + description: "One locally resolved candidate is published before batch acceptance" + ) + let observedStatusTask = Task { () -> [WorkspaceCodemapCurrentProjectionSnapshot] in + var snapshots: [WorkspaceCodemapCurrentProjectionSnapshot] = [] + var fulfilledIntermediateProgress = false + for await snapshot in statusStream { + snapshots.append(snapshot) + if case let .pending(_, progress, inBatchProgress, _, _) = snapshot, + progress.counts.processedCandidateCount == 0, + inBatchProgress?.resolvedCandidateCount == 1, + !fulfilledIntermediateProgress + { + fulfilledIntermediateProgress = true + intermediateProgressObserved.fulfill() + } + if case .authoritativeComplete = snapshot { + return snapshots + } + } + return snapshots + } + defer { observedStatusTask.cancel() } + + let schedule = await fixture.engine.scheduleProjectionPreload(rootEpoch: fixture.rootEpoch) + XCTAssertEqual(schedule, .handedOff) + let allCandidatesEntered = await candidateGate.waitUntilEntered(candidateCount) + XCTAssertTrue(allCandidatesEntered) + await candidateGate.releaseOne() + await fulfillment(of: [intermediateProgressObserved], timeout: 2) + + let snapshotsBeforeAcceptance = await recorder.snapshots + XCTAssertTrue(snapshotsBeforeAcceptance.isEmpty) + let intermediateSnapshot = await fixture.engine.currentProjectionSnapshot( + rootEpoch: fixture.rootEpoch + ) + guard case let .pending(_, progress, inBatchProgress, _, _) = intermediateSnapshot else { + return XCTFail("Expected pending in-batch projection progress.") + } + XCTAssertEqual(progress.counts.processedCandidateCount, 0) + XCTAssertEqual(inBatchProgress?.resolvedCandidateCount, 1) + XCTAssertEqual(inBatchProgress?.locallyResolvedCandidateCountThroughRoot, 1) + + await candidateGate.releaseAll() + let completed = await waitForEngineCondition { + await fixture.engine.accounting().projectionRoots.first?.phase == .complete + } + guard completed else { + observedStatusTask.cancel() + return XCTFail("Expected projection preload completion after releasing the candidates.") + } + let observedStatuses = await observedStatusTask.value + guard case let .authoritativeComplete(proof, _) = observedStatuses.last else { + return XCTFail("Expected the snapshot stream to finish at authoritative completion.") + } + XCTAssertEqual(proof.counts.processedCandidateCount, UInt64(candidateCount)) + XCTAssertEqual(proof.counts.supportedCandidateCount, UInt64(candidateCount)) + for (previous, current) in zip(observedStatuses, observedStatuses.dropFirst()) { + XCTAssertNotEqual(previous, current) + } + } + + func testInBatchProgressKeepsPageStartBaselineWhenLaterSegmentRetries() async throws { + let candidateCount = 8 + let paths = (0 ..< candidateCount).map { "Sources/RetryProgress\($0).swift" } + let repository = try makeRepositoryFixture(name: #function) + let root = try repository.makeRepository( + named: "repository", + files: Dictionary(uniqueKeysWithValues: paths.map { path in + ( + path, + SwiftFixtureSource.emptyStruct( + URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent + ) + ) + }) + ) + let rereadGate = EngineAsyncGate() + addTeardownBlock { rereadGate.release() } + let publisher = EngineProjectionPartialPageRetryPublisher(rereadGate: rereadGate) + let runtime = try CodeMapArtifactRuntime( + rootURL: makeSecureDirectory(in: repository.sandbox, named: "artifacts") + ) + let recorder = EngineProjectionRecorder() + let fixture = try await makeEngineFixture( + root: root, + runtime: runtime, + policy: WorkspaceCodemapBindingEnginePolicy( + maximumRetainedProjectionByteCountPerSegment: 2048, + projectionRetryInitialMilliseconds: 25, + projectionRetryMaximumMilliseconds: 25, + projectionRetryJitterPercent: 0 + ), + projectionCatalogFactory: { rootEpoch, fileIDs in + let candidates = paths.map { path in + WorkspaceCodemapProjectionCatalogCandidate( + identity: WorkspaceCodemapArtifactBindingIdentity( + rootID: rootEpoch.rootID, + rootLifetimeID: rootEpoch.rootLifetimeID, + fileID: fileIDs.id(for: path), + standardizedRootPath: root.path, + standardizedRelativePath: path, + standardizedFullPath: root.appendingPathComponent(path).path + )!, + language: .swift, + requestGeneration: 1, + pathGeneration: 1 + ) + } + return EngineProjectionCatalogStub( + rootEpoch: rootEpoch, + entries: candidates, + recorder: recorder, + publishProjectionOverride: { snapshot in + await publisher.publish(snapshot) + } + ).client + } + ) + guard case .registered = await fixture.engine.registerRoot(fixture.registration) else { + return XCTFail("Expected eligible registration.") + } + + let schedule = await fixture.engine.scheduleProjectionPreload(rootEpoch: fixture.rootEpoch) + XCTAssertEqual(schedule, .handedOff) + let rereadReachedPublication = await rereadGate.waitUntilEntered() + guard rereadReachedPublication else { + let publications = await publisher.snapshots + let description = publications.map { snapshot in + switch snapshot { + case let .segment(segment): + let individualBytes = segment.entries.compactMap { entry -> UInt64? in + guard case let .success(bytes) = + WorkspaceCodemapSelectionGraphProjectionByteAccounting.normalizedByteCount( + entries: [entry] + ) + else { return nil } + return bytes + } + return "segment(entries: \(segment.entries.count), bytes: \(segment.byteCount), individual: \(individualBytes))" + case .seal: + return "seal" + } + }.joined(separator: ", ") + return XCTFail( + "Expected two segments, a retry on the later segment, and a page reread; got \(description)." + ) + } + + let snapshot = await fixture.engine.currentProjectionSnapshot(rootEpoch: fixture.rootEpoch) + guard case let .pending(_, progress, inBatchProgress, _, _) = snapshot else { + return XCTFail("Expected pending progress while the reread publication is blocked.") + } + XCTAssertGreaterThan(progress.counts.processedCandidateCount, 0) + XCTAssertLessThan(progress.counts.processedCandidateCount, UInt64(candidateCount)) + XCTAssertEqual(inBatchProgress?.acceptedProcessedCandidateBaseline, 0) + XCTAssertEqual(inBatchProgress?.resolvedCandidateCount, UInt64(candidateCount)) + XCTAssertEqual( + inBatchProgress?.locallyResolvedCandidateCountThroughRoot, + UInt64(candidateCount) + ) + XCTAssertEqual( + max( + progress.counts.processedCandidateCount, + inBatchProgress?.locallyResolvedCandidateCountThroughRoot ?? 0 + ), + UInt64(candidateCount) + ) + + rereadGate.release() + await fixture.engine.unloadRoot(rootEpoch: fixture.rootEpoch) + } + func testProjectionDemandJoinsActiveBatchWithoutPreemptionAndBecomesExactReady() async throws { let repository = try makeRepositoryFixture(name: #function) let root = try repository.makeRepository( @@ -170,6 +425,23 @@ final class CodemapBindingEngineProjectionTests: CodemapBindingEngineTestCase { await fixture.engine.releaseProjectionDemand(ticket) let releasedAccounting = await fixture.engine.accounting() XCTAssertEqual(releasedAccounting.retainedProjectionDemandCount, 0) + + let completedAcquisition = await fixture.engine.acquireProjectionDemand( + rootEpoch: fixture.rootEpoch, + fileIDs: [fixture.fileIDs.id(for: "Sources/Requested.swift")], + catalogGeneration: 1, + ingressGeneration: 1, + deadlineUptimeNanoseconds: .max, + owner: WorkspaceCodemapLiveDemandOwner() + ) + guard case let .acquired(completedTicket, completedStatus) = completedAcquisition, + case .ready = completedStatus + else { + return XCTFail("Expected the completed projection to satisfy demand immediately.") + } + let completedAccounting = await fixture.engine.accounting() + XCTAssertEqual(completedAccounting.counters.projectionDemandsJoined, 1) + await fixture.engine.releaseProjectionDemand(completedTicket) } func testProjectionDemandClockExpiryBoundsRetryClampAndRevocationAreDeterministic() async throws { diff --git a/Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift b/Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift index d0d19471b..e78b6d7dd 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift +++ b/Tests/RepoPromptTests/WorkspaceContext/CodemapPreloadTests.swift @@ -265,6 +265,97 @@ final class CodemapPreloadTests: WorkspaceFileContextStoreCodemapSeamTestSupport await store.unloadRoot(id: loaded.id) } + func testSingleFileEditRetainsUnchangedCoverageWhileProjectionReschedules() async throws { + let repository = try ReviewGitRepositoryFixture(name: #function) + let paths = [ + "Sources/First.swift", + "Sources/Second.swift", + "Sources/Third.swift" + ] + let root = try repository.makeRepository( + named: "repository", + files: Dictionary(uniqueKeysWithValues: paths.map { path in + ( + path, + SwiftFixtureSource.emptyStruct( + URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent + ) + ) + }) + ) + let fixture = try CodemapStoreFixture(name: #function) + let rescheduleGate = CodemapRootSuspensionGate() + addTeardownBlock { + rescheduleGate.release() + await fixture.shutdown() + repository.cleanup() + } + let store = fixture.makeStore() + let loaded = try await store.loadRoot(path: root.path) + + try await AsyncTestWait.waitUntil("initial Code Map coverage", timeout: 5) { + let status = await store.codemapRootStatusSnapshot(rootID: loaded.id) + return status?.state == .ready && status?.processedCandidateCount == UInt64(paths.count) + } + let initialSnapshot = await store.codemapRootStatusSnapshot(rootID: loaded.id) + let initial = try XCTUnwrap(initialSnapshot) + XCTAssertEqual(initial.processedCandidateCount, UInt64(paths.count)) + XCTAssertEqual(initial.totalCandidateCount, UInt64(paths.count)) + + await store.setCodemapProjectionPreloadStartHandlerForTesting { rootEpoch in + await rescheduleGate.enterAndWait(rootEpoch) + } + let statusStream = await store.codemapRootStatusUpdates() + let postEditStatusObserved = expectation( + description: "The first post-edit status preserves unchanged coverage" + ) + let postEditStatusTask = Task { () -> WorkspaceCodemapRootStatusSnapshot? in + var observedInitialReady = false + for await update in statusStream { + guard let status = update.roots.first(where: { $0.rootEpoch.rootID == loaded.id }) else { + continue + } + if status.state == .ready { + observedInitialReady = true + continue + } + if observedInitialReady { + postEditStatusObserved.fulfill() + return status + } + } + return nil + } + defer { postEditStatusTask.cancel() } + + try Self.write( + "struct Second { let changed = true }\n", + to: root.appendingPathComponent("Sources/Second.swift") + ) + await store.replayObservedFileSystemDeltas( + rootID: loaded.id, + deltas: [.fileModified("Sources/Second.swift", nil)] + ) + let enteredEpoch = await rescheduleGate.waitUntilEntered() + let blockedEpoch = try XCTUnwrap(enteredEpoch) + XCTAssertEqual(blockedEpoch.rootID, loaded.id) + await fulfillment(of: [postEditStatusObserved], timeout: 2) + + let observedPostEditStatus = await postEditStatusTask.value + let postEdit = try XCTUnwrap(observedPostEditStatus) + XCTAssertNotEqual(postEdit.state, .ready) + XCTAssertEqual(postEdit.processedCandidateCount, UInt64(paths.count - 1)) + XCTAssertEqual(postEdit.displayProcessedCandidateCount, UInt64(paths.count - 1)) + XCTAssertEqual(postEdit.totalCandidateCount, UInt64(paths.count)) + + rescheduleGate.release() + try await AsyncTestWait.waitUntil("replacement Code Map coverage", timeout: 5) { + let status = await store.codemapRootStatusSnapshot(rootID: loaded.id) + return status?.state == .ready && status?.processedCandidateCount == UInt64(paths.count) + } + await store.unloadRoot(id: loaded.id) + } + func testUnloadCancelsAndDrainsBlockedProjectionPreloadLaunch() async throws { let fixture = try CodemapStoreFixture(name: #function) let startGate = CodemapRootSuspensionGate() @@ -299,6 +390,191 @@ final class CodemapPreloadTests: WorkspaceFileContextStoreCodemapSeamTestSupport XCTAssertEqual(fixture.providerAccessCount.value, 0) } + func testPauseCancelsBlockedPreloadRejectsDemandAndResumeReschedules() async throws { + let fixture = try CodemapStoreFixture(name: #function) + let startGate = EngineBuildGate() + addTeardownBlock { + startGate.release() + await fixture.shutdown() + } + let root = try fixture.makePlainRoot(files: [ + "Sources/Feature.swift": SwiftFixtureSource.emptyStruct("Feature") + ]) + let store = fixture.makeStore(codemapProjectionPreloadLaunchPolicy: .enabled) + await store.setCodemapProjectionPreloadStartHandlerForTesting { _ in + await startGate.enterIgnoringCancellationUntilRelease() + } + let loaded = try await store.loadRoot(path: root.path) + let didEnter = await startGate.waitUntilEntered() + XCTAssertTrue(didEnter) + let files = await store.files(inRoot: loaded.id) + let file = try XCTUnwrap(files.first) + + let pauseResult = await store.setCodemapGenerationSuspended( + rootID: loaded.id, + suspended: true + ) + XCTAssertEqual(pauseResult, .changed) + let pausedStatus = await store.codemapRootStatusSnapshot(rootID: loaded.id) + XCTAssertEqual(pausedStatus?.state, .paused) + let demand = await store.requestCodemapArtifact(forFileID: file.id) + guard case .unavailable(.cancelled) = demand else { + return XCTFail("Expected paused root demand to be cancelled.") + } + XCTAssertEqual(fixture.providerAccessCount.value, 0) + + let resumeFinished = EngineLockedFlag() + let resumeTask = Task { + let result = await store.setCodemapGenerationSuspended(rootID: loaded.id, suspended: false) + resumeFinished.set(true) + return result + } + try? await Task.sleep(for: .milliseconds(10)) + XCTAssertFalse(resumeFinished.value) + let repauseResult = await store.setCodemapGenerationSuspended( + rootID: loaded.id, + suspended: true + ) + XCTAssertEqual(repauseResult, .changed) + startGate.release() + let cancelledResumeResult = await resumeTask.value + XCTAssertEqual(cancelledResumeResult, .unchanged) + let eventsAfterCancelledResume = await store.codemapProjectionPreloadStoreEventsForTesting(rootID: loaded.id) + XCTAssertEqual(eventsAfterCancelledResume.count { $0.kind == .scheduled }, 1) + + let resumeResult = await store.setCodemapGenerationSuspended( + rootID: loaded.id, + suspended: false + ) + XCTAssertEqual(resumeResult, .changed) + let didReschedule = await waitForCodemapPreloadEventCount( + store: store, + rootID: loaded.id, + kind: .scheduled, + count: 2 + ) + XCTAssertTrue(didReschedule) + let resumedStatus = await store.codemapRootStatusSnapshot(rootID: loaded.id) + XCTAssertNotEqual(resumedStatus?.state, .paused) + await store.unloadRoot(id: loaded.id) + } + + func testRootStatusNormalizesSuspendedBusyAsGeneratingAndBudgetLimitedAsWaiting() async throws { + let fixture = try CodemapStoreFixture(name: #function, projectionAuthority: .manual) + addTeardownBlock { await fixture.shutdown() } + let root = try fixture.makePlainRoot(files: [ + "Sources/Feature.swift": SwiftFixtureSource.emptyStruct("Feature") + ]) + let store = fixture.makeStore() + let loaded = try await store.loadRoot(path: root.path) + + await store.setCodemapProjectionPhaseForTesting( + rootID: loaded.id, + phase: .suspendedBusy + ) + let suspendedBusyStatus = await store.codemapRootStatusSnapshot(rootID: loaded.id) + XCTAssertEqual(suspendedBusyStatus?.state, .generating) + + await store.setCodemapProjectionPhaseForTesting( + rootID: loaded.id, + phase: .budgetLimited + ) + let budgetLimitedStatus = await store.codemapRootStatusSnapshot(rootID: loaded.id) + XCTAssertEqual(budgetLimitedStatus?.state, .waiting) + + await store.unloadRoot(id: loaded.id) + } + + func testRootStatusStreamBridgesLocalCandidateResolutionUntilDurableCompletion() async throws { + let candidateCount = 5 + let paths = (0 ..< candidateCount).map { "Sources/Progress\($0).swift" } + let repository = try ReviewGitRepositoryFixture(name: #function) + let root = try repository.makeRepository( + named: "repository", + files: Dictionary(uniqueKeysWithValues: paths.map { path in + ( + path, + SwiftFixtureSource.emptyStruct( + URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent + ) + ) + }) + ) + let candidateGate = EngineMultiEntryGate() + let fixture = try CodemapStoreFixture( + name: #function, + artifactBuilder: CodeMapArtifactBuilderClient(build: { _, _, _ in + await candidateGate.enter() + return .readyNoSymbols + }), + artifactCoordinatorPolicy: CodeMapArtifactBuildCoordinatorPolicy( + maximumConcurrentBuildCount: candidateCount + ), + bindingEnginePolicy: WorkspaceCodemapBindingEnginePolicy( + maximumConcurrentMaterializationCountPerOwner: candidateCount + ) + ) + addTeardownBlock { + await candidateGate.releaseAll() + await fixture.shutdown() + repository.cleanup() + } + let store = fixture.makeStore() + let loaded = try await store.loadRoot(path: root.path) + let statusStream = await store.codemapRootStatusUpdates() + let intermediateObserved = expectation( + description: "Root status exposes locally resolved progress" + ) + let completionObserved = expectation(description: "Root status reaches durable completion") + let observedStatusTask = Task { () -> [WorkspaceCodemapRootStatusSnapshot] in + var snapshots: [WorkspaceCodemapRootStatusSnapshot] = [] + var fulfilledIntermediate = false + var fulfilledCompletion = false + for await update in statusStream { + guard let snapshot = update.roots.first(where: { $0.rootEpoch.rootID == loaded.id }) else { + continue + } + snapshots.append(snapshot) + if snapshot.processedCandidateCount == 0, + snapshot.locallyResolvedCandidateCountThroughRoot == 1, + !fulfilledIntermediate + { + fulfilledIntermediate = true + intermediateObserved.fulfill() + } + if snapshot.state == .ready, !fulfilledCompletion { + fulfilledCompletion = true + completionObserved.fulfill() + return snapshots + } + } + return snapshots + } + defer { observedStatusTask.cancel() } + + let allCandidatesEntered = await candidateGate.waitUntilEntered(candidateCount) + XCTAssertTrue(allCandidatesEntered) + await candidateGate.releaseOne() + await fulfillment(of: [intermediateObserved], timeout: 2) + + let currentStatus = await store.codemapRootStatusSnapshot(rootID: loaded.id) + let intermediate = try XCTUnwrap(currentStatus) + XCTAssertEqual(intermediate.processedCandidateCount, 0) + XCTAssertEqual(intermediate.locallyResolvedCandidateCountThroughRoot, 1) + XCTAssertEqual(intermediate.displayProcessedCandidateCount, 1) + XCTAssertEqual(intermediate.totalCandidateCount, UInt64(candidateCount)) + + await candidateGate.releaseAll() + await fulfillment(of: [completionObserved], timeout: 5) + let observedStatuses = await observedStatusTask.value + let completed = try XCTUnwrap(observedStatuses.last) + XCTAssertEqual(completed.state, .ready) + XCTAssertEqual(completed.processedCandidateCount, UInt64(candidateCount)) + XCTAssertNil(completed.locallyResolvedCandidateCountThroughRoot) + XCTAssertEqual(completed.displayProcessedCandidateCount, UInt64(candidateCount)) + await store.unloadRoot(id: loaded.id) + } + func testFirstProjectionPageLazilyPublishesRecordsOnlyShardAfterRootReady() async throws { let registrationGate = CodemapResolutionGate() let fixture = try CodemapStoreFixture(name: #function, resolutionGate: registrationGate) @@ -338,6 +614,8 @@ final class CodemapPreloadTests: WorkspaceFileContextStoreCodemapSeamTestSupport )) ) XCTAssertEqual(page.entries.map(\.identity.fileID), [file.id]) + let mappingStatus = await store.codemapRootStatusSnapshot(rootID: loaded.id) + XCTAssertEqual(mappingStatus?.totalCandidateCount, 1) let after = await store.storeWorkDiagnosticsSnapshot() let shard = try XCTUnwrap(after.rootCatalogShards.roots.first { $0.rootID == loaded.id }) XCTAssertEqual(after.rootCatalogShards.publishedShardCount, 1)