diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md
index 229adf0..04bec85 100644
--- a/docs/SETTINGS.md
+++ b/docs/SETTINGS.md
@@ -254,7 +254,7 @@ Window size and position are saved in `%LOCALAPPDATA%/BuildMonitor/windows-layou
## Projects — start on launch
-- **`startOnLaunch`** (default **true** for new projects; migrated from global `monitor.autoStartActiveProjectsOnLaunch` in schema v10) — per project. When **true** and **active in session**, the project builds and runs automatically when the app starts or after you save settings. When **false**, the project stays monitored but idle until you use **Rebuild** / **Restart** from the tray. Settings → **Projects** → select project → *Start build when app launches*.
+- **`startOnLaunch`** (default **true** for new projects; migrated from global `monitor.autoStartActiveProjectsOnLaunch` in schema v10) — per project. When **true** and **active in session**, the project builds and runs automatically on **cold BuildMonitor/session startup**. It does **not** trigger a build when Settings Save remounts a Local runtime (HardRestart settings remount watcher/process without compiling). When **false**, the project stays monitored but idle until you use **Rebuild** / **Restart** from the tray. Settings → **Projects** → select project → *Start build when app launches*.
- **`buildControlMode`** (schema v19; default **FileWatching**) — per project. `FileWatching` = debounced auto-build on source changes (held while control-plane busy). `AiControlled` = file watcher observes/counts only; builds require tray Rebuild or `POST /run/rebuild` / `/run/ship-check`. Settings → **Projects** → **Build control**. Wire API uses `file-watching` / `ai-controlled`.
- **`preferredSiteUrlScheme`** (schema v20; default **Auto**) — per project. When the launch profile lists both HTTP and HTTPS, which URL to show/open: `Auto` (prefer HTTPS), `Https`, or `Http`. Settings → **Projects** → **Preferred site URL**. Also waits briefly for the preferred scheme before locking onto the first open port.
diff --git a/docs/features/health-and-logs.md b/docs/features/health-and-logs.md
index 4c4815f..38a61d8 100644
--- a/docs/features/health-and-logs.md
+++ b/docs/features/health-and-logs.md
@@ -38,16 +38,16 @@ During large builds MSBuild can emit thousands of lines. Parsing issue counts an
Settings Save is classified by `SettingsApplyImpactClassifier` using the exhaustive
`SettingsApplyImpactCatalog` (every persisted leaf path under `AppSettings`):
-| Impact | Example | StopAll + StartActive (may build) |
-|--------|---------|-------------------------------------|
+| Impact | Example | Local action |
+|--------|---------|--------------|
| Presentation | Tray menu layout, theme, toasts, VD follow | No |
| SoftRuntime | Monitor, Azure, display name, test/restart/build-control policies, Local UI prefs | No (orchestrator `UpdateDefinition` only) |
-| HardRestart | Local Id/active, RootFolder/ProjectFile/launch/args, RunMode, WatchExcludeSegments | Yes |
+| HardRestart | Local Id/active, RootFolder/ProjectFile/launch/args, RunMode, WatchExcludeSegments | Remount **affected** Local runtimes **without** `BuildAsync` |
| None | Identical save / schema version only | No |
Azure-only project add/active toggles are **SoftRuntime** (not HardRestart). Presentation-only saves still refresh the tray menu immediately; they must not schedule a Local rebuild.
-**HardRestart is reserved for settings that invalidate the live Local process/watcher context.** Policy knobs read on the next crash/build/test (RunTests, TestProjectFile, restart flags, BuildControlMode, FileChanges, lock/repair) are SoftRuntime. There is no separate “restart process without rebuild” apply path yet — changing RootFolder/ProjectFile/RunMode still uses StopAll + StartActive (may build when StartOnLaunch is on).
+**HardRestart invalidates live Local process/watcher context for the changed project(s) only** when practical. Remount recreates the watcher and may restart the app with `--no-build`; it never compiles solely because Settings were saved. Cold BuildMonitor startup (`before == null`) still uses `StartAsync` / StartOnLaunch startup builds. Policy knobs read on the next crash/build/test (RunTests, TestProjectFile, restart flags, BuildControlMode, FileChanges, lock/repair) remain SoftRuntime.
Coverage: `SettingsApplyImpactClassifierTests.Catalog_covers_every_discovered_persisted_leaf_path` fails if a new persisted property is added without a catalog entry. Mutation theories assert each catalog path yields its declared impact.
diff --git a/docs/ops/ai-controlled-build-isolation.md b/docs/ops/ai-controlled-build-isolation.md
index 23bc738..a52f36f 100644
--- a/docs/ops/ai-controlled-build-isolation.md
+++ b/docs/ops/ai-controlled-build-isolation.md
@@ -19,7 +19,7 @@ When `buildControlMode` is **AI Controlled**, source-file changes must never ini
| `TryHandleHotReloadRestartRequest` | May rebuild/restart | Blocked unless explicit agent rebuild/ship-check in progress |
| Busy timeout → idle | May resume auto-build | Policy still false; no schedule started |
| `/session/idle` | May resume debounce build | Does not schedule in AI Controlled |
-| Startup / `StartOnLaunch` | Initial build | Allowed (not a source-change schedule) |
+| Startup / `StartOnLaunch` | Initial build on **cold** BuildMonitor/session start | Allowed (not a source-change schedule). Settings HardRestart remounts never compile, including AI Controlled + StartOnLaunch. |
| Manual Rebuild / Restart / Tests | Explicit | Allowed |
| `/run/rebuild`, `/run/ship-check`, `/run/tests`, `/run/stop` | Explicit | Allowed |
diff --git a/src/BuildMonitor.Tests/BuildSourcePresentationNavigationTests.cs b/src/BuildMonitor.Tests/BuildSourcePresentationNavigationTests.cs
new file mode 100644
index 0000000..7c7e729
--- /dev/null
+++ b/src/BuildMonitor.Tests/BuildSourcePresentationNavigationTests.cs
@@ -0,0 +1,160 @@
+using BuildMonitor.Core.Models;
+using BuildMonitor.Core.Rules;
+using BuildMonitor.Infrastructure.AzureDevOps;
+
+namespace BuildMonitor.Tests;
+
+/// Azure BUILDS row deep-link and column semantics (presentation layer).
+public sealed class BuildSourcePresentationNavigationTests
+{
+ private static readonly DateTimeOffset Now = new(2026, 8, 28, 6, 0, 0, TimeSpan.Zero);
+ private const string RunUrl = "https://dev.azure.com/org/project/_build/results?buildId=491&view=results";
+
+ [Fact]
+ public void Azure_primary_row_deep_link_targets_PrimaryRun_build_results()
+ {
+ var primary = AzureRun(runId: 491, buildNumber: "20260828.3", pullRequestNumber: 185);
+ var facet = Facet(primary);
+ var row = Assert.Single(BuildSourcePresentationBuilder.BuildAzureRows(facet, true, true, Now));
+
+ Assert.Equal("#491", row.RunDisplay);
+ Assert.Equal("20260828.3", row.BuildNumberDisplay);
+ Assert.Equal("#185", row.PullRequestDisplay);
+ Assert.Equal(RunUrl, row.DeepLinkUrl);
+ Assert.NotEqual(row.RunDisplay, row.BuildNumberDisplay);
+ }
+
+ [Fact]
+ public void Azure_primary_uses_PrimaryRun_not_attention_or_previous_run()
+ {
+ var primary = AzureRun(runId: 491, buildNumber: "20260828.3", pullRequestNumber: 185);
+ var attentionFailed = AzureRun(
+ runId: 488,
+ buildNumber: "20260828.1",
+ pullRequestNumber: null,
+ state: PipelineRunState.Completed,
+ result: PipelineRunResult.Failed);
+ var facet = Facet(primary, attentionFailed);
+ var row = Assert.Single(BuildSourcePresentationBuilder.BuildAzureRows(facet, true, true, Now));
+
+ Assert.Equal("#491", row.RunDisplay);
+ Assert.Equal(RunUrl, row.DeepLinkUrl);
+ Assert.DoesNotContain("488", row.DeepLinkUrl, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Azure_non_pr_run_has_dash_pull_request_display_and_same_run_deep_link()
+ {
+ var primary = AzureRun(runId: 452, buildNumber: "20260825.13", pullRequestNumber: null, branch: "master");
+ var facet = Facet(primary);
+ var row = Assert.Single(BuildSourcePresentationBuilder.BuildAzureRows(facet, true, true, Now));
+
+ Assert.Equal("—", row.PullRequestDisplay);
+ Assert.Contains("buildId=452", row.DeepLinkUrl!, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Azure_message_mode_has_no_fabricated_deep_link()
+ {
+ var facet = new ProjectAzureHealthFacet(
+ AzureMonitoringAvailability.AuthRequired,
+ AzureCiMonitoringState.NotMonitored,
+ FocusBranch: "master",
+ PrimaryRun: null,
+ AttentionRuns: [],
+ PolledAtUtc: Now,
+ HasSelectedPipelines: true,
+ StatusMessage: "Sign in required");
+
+ var row = Assert.Single(BuildSourcePresentationBuilder.BuildAzureRows(facet, true, true, Now));
+
+ Assert.Null(row.DeepLinkUrl);
+ Assert.Equal("—", row.RunDisplay);
+ Assert.Equal("—", row.PullRequestDisplay);
+ }
+
+ [Fact]
+ public void Local_row_has_no_azure_deep_link()
+ {
+ var snapshot = new ProjectHealthSnapshot(
+ ProjectId: "p1",
+ DisplayName: "Demo",
+ Health: MonitorHealth.Green,
+ HealthLabel: "Healthy",
+ State: ProjectLifecycleState.Watching,
+ LastExitCode: 0,
+ LastDuration: TimeSpan.FromSeconds(4),
+ LastErrorPreview: null,
+ ErrorCount: 0,
+ WarningCount: 0,
+ LastChangedUtc: Now,
+ LastBuildFinishedAtUtc: Now.AddMinutes(-1),
+ IsActive: true,
+ ProgressSteps: [],
+ LastBuildExitCode: 0,
+ Azure: Facet(AzureRun(491, "20260828.3", 185)));
+ var controlPlane = ControlPlaneStatusFormatter.Format(snapshot, Now);
+ var local = BuildSourcePresentationBuilder.TryBuildLocal(snapshot, controlPlane, Now);
+
+ Assert.NotNull(local);
+ Assert.Null(local!.DeepLinkUrl);
+ Assert.Equal("—", local.RunDisplay);
+ Assert.Equal("—", local.PullRequestDisplay);
+ }
+
+ [Fact]
+ public void Deep_link_builder_uses_build_id_not_build_number()
+ {
+ var url = AzureDevOpsDeepLinkBuilder.BuildRunResultsUrl(
+ "https://dev.azure.com/org",
+ "project",
+ 491);
+
+ Assert.Contains("buildId=491", url, StringComparison.Ordinal);
+ Assert.DoesNotContain("20260828", url, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Azure_table_row_run_url_matches_primary_run()
+ {
+ var run = AzureRun(491, "20260828.3", 185);
+ var tableRow = AzureStatusPresentationBuilder.ToTableRow(run, Now);
+
+ Assert.Equal("#491", tableRow.RunDisplay);
+ Assert.Equal(RunUrl, tableRow.RunUrl);
+ Assert.Equal("#185", tableRow.PullRequestDisplay);
+ }
+
+ private static AzurePipelineRunInfo AzureRun(
+ long runId,
+ string buildNumber,
+ int? pullRequestNumber,
+ string branch = "PR #185",
+ PipelineRunState state = PipelineRunState.InProgress,
+ PipelineRunResult? result = null) =>
+ new(
+ DefinitionId: 8,
+ PipelineDisplayName: "WitherbyConnect",
+ RunId: runId,
+ BuildNumber: buildNumber,
+ State: state,
+ Result: result ?? PipelineRunResult.Unknown,
+ Branch: branch,
+ QueuedAtUtc: Now.AddMinutes(-5),
+ StartedAtUtc: Now.AddMinutes(-4),
+ FinishedAtUtc: null,
+ RunUrl: $"https://dev.azure.com/org/project/_build/results?buildId={runId}&view=results",
+ PullRequestNumber: pullRequestNumber);
+
+ private static ProjectAzureHealthFacet Facet(
+ AzurePipelineRunInfo primary,
+ params AzurePipelineRunInfo[] attention) =>
+ new(
+ AzureMonitoringAvailability.Available,
+ AzureCiMonitoringState.Activity,
+ FocusBranch: "master",
+ primary,
+ attention,
+ Now,
+ HasSelectedPipelines: true);
+}
diff --git a/src/BuildMonitor.Tests/SettingsApplyImpactClassifierTests.cs b/src/BuildMonitor.Tests/SettingsApplyImpactClassifierTests.cs
index 22feeea..6246e2f 100644
--- a/src/BuildMonitor.Tests/SettingsApplyImpactClassifierTests.cs
+++ b/src/BuildMonitor.Tests/SettingsApplyImpactClassifierTests.cs
@@ -44,7 +44,7 @@ public void Identical_settings_are_none_and_do_not_restart()
var settings = SampleSettings();
var plan = SettingsApplyImpactClassifier.CreatePlan(settings, Clone(settings));
Assert.Equal(SettingsApplyImpact.None, plan.Impact);
- Assert.False(plan.StopAllAndRestartActiveProjects);
+ Assert.False(plan.TouchesLocalRuntimes);
Assert.False(plan.ApplyOrchestratorSettings);
Assert.False(plan.ShowProjectsStartingToast);
}
@@ -58,7 +58,7 @@ public void TrayMenuLayout_only_is_presentation_with_zero_restarts()
var plan = SettingsApplyImpactClassifier.CreatePlan(before, after);
Assert.Equal(SettingsApplyImpact.Presentation, plan.Impact);
- Assert.False(plan.StopAllAndRestartActiveProjects);
+ Assert.False(plan.TouchesLocalRuntimes);
Assert.False(plan.ApplyOrchestratorSettings);
Assert.False(plan.ResetHealthTransitionState);
Assert.False(plan.ShowProjectsStartingToast);
@@ -92,7 +92,7 @@ public void Azure_attachment_only_is_soft_runtime_without_local_restart()
var plan = SettingsApplyImpactClassifier.CreatePlan(before, after);
Assert.Equal(SettingsApplyImpact.SoftRuntime, plan.Impact);
- Assert.False(plan.StopAllAndRestartActiveProjects);
+ Assert.False(plan.TouchesLocalRuntimes);
Assert.True(plan.ApplyOrchestratorSettings);
Assert.False(plan.ShowProjectsStartingToast);
}
@@ -132,7 +132,7 @@ public void Monitor_debounce_only_is_soft_runtime()
var plan = SettingsApplyImpactClassifier.CreatePlan(before, after);
Assert.Equal(SettingsApplyImpact.SoftRuntime, plan.Impact);
- Assert.False(plan.StopAllAndRestartActiveProjects);
+ Assert.False(plan.TouchesLocalRuntimes);
}
[Fact]
@@ -144,9 +144,9 @@ public void Local_project_file_change_is_hard_restart()
var plan = SettingsApplyImpactClassifier.CreatePlan(before, after);
Assert.Equal(SettingsApplyImpact.HardRestart, plan.Impact);
- Assert.True(plan.StopAllAndRestartActiveProjects);
+ Assert.True(plan.TouchesLocalRuntimes);
Assert.True(plan.ApplyOrchestratorSettings);
- Assert.True(plan.ShowProjectsStartingToast);
+ Assert.False(plan.ShowProjectsStartingToast); Assert.True(plan.RemountAffectedLocalProjectsWithoutBuild);
}
[Fact]
@@ -209,7 +209,7 @@ public void Ai_controlled_mode_change_is_soft_runtime()
var plan = SettingsApplyImpactClassifier.CreatePlan(before, after);
Assert.Equal(SettingsApplyImpact.SoftRuntime, plan.Impact);
- Assert.False(plan.StopAllAndRestartActiveProjects);
+ Assert.False(plan.TouchesLocalRuntimes);
Assert.True(plan.ApplyOrchestratorSettings);
}
@@ -240,7 +240,7 @@ public void Restart_policy_flags_are_soft_runtime()
var plan = SettingsApplyImpactClassifier.CreatePlan(before, after);
Assert.Equal(SettingsApplyImpact.SoftRuntime, plan.Impact);
- Assert.False(plan.StopAllAndRestartActiveProjects);
+ Assert.False(plan.TouchesLocalRuntimes);
}
[Fact]
@@ -254,7 +254,10 @@ public void RunMode_change_is_hard_restart()
var plan = SettingsApplyImpactClassifier.CreatePlan(before, after);
Assert.Equal(SettingsApplyImpact.HardRestart, plan.Impact);
- Assert.True(plan.StopAllAndRestartActiveProjects);
+ Assert.True(plan.TouchesLocalRuntimes);
+ Assert.True(plan.RemountAffectedLocalProjectsWithoutBuild);
+ Assert.False(plan.ColdStartActiveProjectsWithBuild);
+ Assert.False(plan.ShowProjectsStartingToast);
}
[Fact]
@@ -270,11 +273,14 @@ public void Watch_exclude_segments_change_is_hard_restart()
}
[Fact]
- public void Null_before_is_hard_restart_like_cold_start()
+ public void Null_before_cold_start_plan_uses_startup_build_not_remount()
{
- Assert.Equal(
- SettingsApplyImpact.HardRestart,
- SettingsApplyImpactClassifier.Classify(null, SampleSettings()));
+ var plan = SettingsApplyImpactClassifier.CreatePlan(null, SampleSettings());
+ Assert.Equal(SettingsApplyImpact.HardRestart, plan.Impact);
+ Assert.True(plan.ColdStartActiveProjectsWithBuild);
+ Assert.False(plan.RemountAffectedLocalProjectsWithoutBuild);
+ Assert.True(plan.ShowProjectsStartingToast);
+ Assert.Empty(plan.LocalRemounts);
}
[Fact]
@@ -286,7 +292,7 @@ public void Display_name_only_is_soft_runtime()
var plan = SettingsApplyImpactClassifier.CreatePlan(before, after);
Assert.Equal(SettingsApplyImpact.SoftRuntime, plan.Impact);
- Assert.False(plan.StopAllAndRestartActiveProjects);
+ Assert.False(plan.TouchesLocalRuntimes);
}
[Fact]
@@ -312,7 +318,7 @@ public static TheoryData CatalogMutationCases()
continue;
}
- // SchemaVersion alone → None (catalog Impact None)
+ // SchemaVersion alone → None (catalog Impact None)
data.Add(entry.Path, entry.Impact);
}
@@ -497,3 +503,4 @@ private static void SetAlteredValue(object target, PropertyInfo prop)
prop.SetValue(target, next);
}
}
+
diff --git a/src/BuildMonitor.Tests/SettingsLocalRemountPlannerTests.cs b/src/BuildMonitor.Tests/SettingsLocalRemountPlannerTests.cs
new file mode 100644
index 0000000..3c6a06f
--- /dev/null
+++ b/src/BuildMonitor.Tests/SettingsLocalRemountPlannerTests.cs
@@ -0,0 +1,290 @@
+using BuildMonitor.Core.Models;
+using BuildMonitor.Core.Rules;
+using BuildMonitor.Core.Settings;
+using BuildMonitor.Infrastructure.Diagnostics;
+using BuildMonitor.Infrastructure.LocalBuild;
+using BuildMonitor.Infrastructure.Services;
+
+namespace BuildMonitor.Tests;
+
+public sealed class SettingsLocalRemountPlannerTests
+{
+ [Fact]
+ public void Watch_exclude_change_is_watcher_only_for_affected_project()
+ {
+ var before = TwoProjects();
+ var after = Clone(before);
+ after.Projects[0].Local!.RunOptions.WatchExcludeSegments = "bin;obj;custom";
+
+ var plans = SettingsLocalRemountPlanner.Plan(before, after);
+ Assert.Single(plans);
+ Assert.Equal(before.Projects[0].Id, plans[0].ProjectId);
+ Assert.Equal(LocalRemountKind.WatcherOnly, plans[0].Kind);
+ Assert.DoesNotContain(plans, p => p.ProjectId == before.Projects[1].Id);
+ }
+
+ [Fact]
+ public void Launch_profile_and_args_and_run_mode_are_process_and_watcher()
+ {
+ var before = Sample();
+ var afterProfile = Clone(before);
+ afterProfile.Projects[0].Local!.LaunchProfile = "https";
+ Assert.Equal(
+ LocalRemountKind.ProcessAndWatcher,
+ Assert.Single(SettingsLocalRemountPlanner.Plan(before, afterProfile)).Kind);
+
+ var afterArgs = Clone(before);
+ afterArgs.Projects[0].Local!.ExtraDotNetArgs = "--verbosity minimal";
+ Assert.Equal(
+ LocalRemountKind.ProcessAndWatcher,
+ Assert.Single(SettingsLocalRemountPlanner.Plan(before, afterArgs)).Kind);
+
+ var afterMode = Clone(before);
+ afterMode.Projects[0].Local!.RunOptions.RunMode = ProjectRunMode.None;
+ Assert.Equal(
+ LocalRemountKind.ProcessAndWatcher,
+ Assert.Single(SettingsLocalRemountPlanner.Plan(before, afterMode)).Kind);
+ }
+
+ [Fact]
+ public void Root_and_project_file_are_source_identity()
+ {
+ var before = Sample();
+ var afterRoot = Clone(before);
+ afterRoot.Projects[0].Local!.RootFolder = @"C:\src\Other";
+ Assert.Equal(
+ LocalRemountKind.SourceIdentity,
+ Assert.Single(SettingsLocalRemountPlanner.Plan(before, afterRoot)).Kind);
+
+ var afterProj = Clone(before);
+ afterProj.Projects[0].Local!.ProjectFile = "Other.csproj";
+ Assert.Equal(
+ LocalRemountKind.SourceIdentity,
+ Assert.Single(SettingsLocalRemountPlanner.Plan(before, afterProj)).Kind);
+ }
+
+ [Fact]
+ public void Active_toggle_stop_and_mount_fresh()
+ {
+ var before = Sample();
+ var afterOff = Clone(before);
+ afterOff.Projects[0].IsActiveInSession = false;
+ Assert.Equal(
+ LocalRemountKind.StopOnly,
+ Assert.Single(SettingsLocalRemountPlanner.Plan(before, afterOff)).Kind);
+
+ var afterOn = Clone(afterOff);
+ afterOn.Projects[0].IsActiveInSession = true;
+ Assert.Equal(
+ LocalRemountKind.MountFresh,
+ Assert.Single(SettingsLocalRemountPlanner.Plan(afterOff, afterOn)).Kind);
+ }
+
+ [Fact]
+ public void Hard_plan_lists_only_changed_project()
+ {
+ var before = TwoProjects();
+ var after = Clone(before);
+ after.Projects[0].Local!.LaunchProfile = "http";
+
+ var plan = SettingsApplyImpactClassifier.CreatePlan(before, after);
+ Assert.True(plan.RemountAffectedLocalProjectsWithoutBuild);
+ Assert.False(plan.ColdStartActiveProjectsWithBuild);
+ Assert.Single(plan.LocalRemounts);
+ Assert.Equal(before.Projects[0].Id, plan.LocalRemounts[0].ProjectId);
+ }
+
+ private static AppSettings Sample() => new()
+ {
+ Projects =
+ [
+ TestProjectFactory.LocalOnly(
+ displayName: "A",
+ id: "a",
+ rootFolder: @"C:\src\A",
+ projectFile: "A.csproj",
+ runOptions: new ProjectRunOptions { RunMode = ProjectRunMode.Watch })
+ ]
+ };
+
+ private static AppSettings TwoProjects()
+ {
+ var s = Sample();
+ s.Projects.Add(TestProjectFactory.LocalOnly(
+ displayName: "B",
+ id: "b",
+ rootFolder: @"C:\src\B",
+ projectFile: "B.csproj",
+ runOptions: new ProjectRunOptions { RunMode = ProjectRunMode.Watch }));
+ return s;
+ }
+
+ private static AppSettings Clone(AppSettings settings) =>
+ System.Text.Json.JsonSerializer.Deserialize(
+ System.Text.Json.JsonSerializer.Serialize(settings))!;
+}
+
+public sealed class ProjectRuntimeRemountWithoutBuildTests
+{
+ [Fact]
+ public async Task Watcher_only_remount_creates_watcher_without_build()
+ {
+ await using var scope = await RemountTestScope.CreateAsync(ProjectRunMode.None, ProjectBuildControlMode.FileWatching);
+ var beforeWatchers = scope.Runtime.WatcherCreateCount;
+
+ await scope.Runtime.RemountWithoutBuildAsync(LocalRemountKind.WatcherOnly, CancellationToken.None);
+
+ Assert.Equal(1, scope.Runtime.RemountWithoutBuildCount);
+ Assert.Equal(0, scope.Runtime.BuildAsyncInvocationCount);
+ Assert.True(scope.Runtime.WatcherCreateCount > beforeWatchers);
+ }
+
+ [Fact]
+ public async Task Ai_controlled_source_identity_remount_with_start_on_launch_does_not_build()
+ {
+ await using var scope = await RemountTestScope.CreateAsync(
+ ProjectRunMode.Watch,
+ ProjectBuildControlMode.AiControlled);
+ scope.Runtime.UpdateDefinition(
+ TestProjectFactory.LocalOnly(
+ id: scope.Runtime.ProjectId,
+ rootFolder: scope.Root,
+ projectFile: "App.csproj",
+ buildControlMode: ProjectBuildControlMode.AiControlled,
+ runOptions: new ProjectRunOptions { RunMode = ProjectRunMode.Watch }),
+ new GlobalMonitorSettings());
+ GetDefinition(scope.Runtime).Local!.StartOnLaunch = true;
+
+ await scope.Runtime.RemountWithoutBuildAsync(LocalRemountKind.SourceIdentity, CancellationToken.None);
+
+ Assert.Equal(1, scope.Runtime.RemountWithoutBuildCount);
+ Assert.Equal(0, scope.Runtime.BuildAsyncInvocationCount);
+ }
+
+ [Fact]
+ public async Task Process_and_watcher_remount_restarts_process_path_without_build()
+ {
+ await using var scope = await RemountTestScope.CreateAsync(
+ ProjectRunMode.Run,
+ ProjectBuildControlMode.FileWatching);
+ SetPrivate(scope.Runtime, "lastBuildExitCode", 0);
+
+ await scope.Runtime.RemountWithoutBuildAsync(LocalRemountKind.ProcessAndWatcher, CancellationToken.None);
+
+ Assert.Equal(1, scope.Runtime.RemountWithoutBuildCount);
+ Assert.Equal(0, scope.Runtime.BuildAsyncInvocationCount);
+ Assert.True(scope.Runtime.ProcessStartCount >= 1);
+ Assert.True(scope.Runtime.WatcherCreateCount >= 1);
+ }
+
+ [Fact]
+ public async Task Mount_fresh_does_not_build()
+ {
+ await using var scope = await RemountTestScope.CreateAsync(
+ ProjectRunMode.Watch,
+ ProjectBuildControlMode.AiControlled);
+
+ await scope.Runtime.RemountWithoutBuildAsync(LocalRemountKind.MountFresh, CancellationToken.None);
+
+ Assert.Equal(0, scope.Runtime.BuildAsyncInvocationCount);
+ Assert.Equal(1, scope.Runtime.RemountWithoutBuildCount);
+ }
+
+ private static MonitoredProjectSettings GetDefinition(ProjectRuntime runtime)
+ {
+ var field = typeof(ProjectRuntime).GetField(
+ "projectSettings",
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ return (MonitoredProjectSettings)field!.GetValue(runtime)!;
+ }
+
+ private static void SetPrivate(object target, string name, object value)
+ {
+ var field = target.GetType().GetField(
+ name,
+ System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ field!.SetValue(target, value);
+ }
+
+ private sealed class RemountTestScope : IAsyncDisposable
+ {
+ private RemountTestScope(ProjectRuntime runtime, string root, string logsRoot, string dataRoot)
+ {
+ Runtime = runtime;
+ Root = root;
+ this.logsRoot = logsRoot;
+ this.dataRoot = dataRoot;
+ }
+
+ public ProjectRuntime Runtime { get; }
+ public string Root { get; }
+ private readonly string logsRoot;
+ private readonly string dataRoot;
+
+ public static Task CreateAsync(
+ ProjectRunMode runMode,
+ ProjectBuildControlMode mode)
+ {
+ var root = CreateTempDir();
+ var logsRoot = CreateTempDir();
+ var dataRoot = CreateTempDir();
+ var definition = TestProjectFactory.LocalOnly(
+ displayName: "RemountProbe",
+ rootFolder: root,
+ projectFile: "App.csproj",
+ buildControlMode: mode,
+ runOptions: new ProjectRunOptions
+ {
+ RunMode = runMode,
+ FileChanges = FileChangeMode.TriggerRebuild
+ });
+ definition.Local!.StartOnLaunch = true;
+
+ var runtime = new ProjectRuntime(
+ definition,
+ new BuildLogStore(logsRoot),
+ new DotNetCliRunner(),
+ new BuildTriggerJournal(dataRoot),
+ new FileChangeBurstStatsStore(dataRoot),
+ new BuildTrainingStore(dataRoot));
+
+ return Task.FromResult(new RemountTestScope(runtime, root, logsRoot, dataRoot));
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ try
+ {
+ await Runtime.StopAsync().ConfigureAwait(false);
+ }
+ catch
+ {
+ // ignore
+ }
+
+ Runtime.Dispose();
+ TryDelete(Root);
+ TryDelete(logsRoot);
+ TryDelete(dataRoot);
+ }
+
+ private static string CreateTempDir()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "bm-remount-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ private static void TryDelete(string path)
+ {
+ try
+ {
+ Directory.Delete(path, recursive: true);
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ }
+}
diff --git a/src/BuildMonitor.Tests/StatusPanelPresentationChangeDetectorUrgentTests.cs b/src/BuildMonitor.Tests/StatusPanelPresentationChangeDetectorUrgentTests.cs
index db07550..2c2b1fa 100644
--- a/src/BuildMonitor.Tests/StatusPanelPresentationChangeDetectorUrgentTests.cs
+++ b/src/BuildMonitor.Tests/StatusPanelPresentationChangeDetectorUrgentTests.cs
@@ -77,6 +77,100 @@ public void RequiresUrgentCardRebuild_when_progress_steps_advance()
Assert.True(StatusPanelPresentationChangeDetector.RequiresUrgentCardRebuild(previous, current));
}
+ [Fact]
+ public void RequiresUrgentCardRebuild_false_when_only_build_source_age_ticks()
+ {
+ var now = new DateTimeOffset(2026, 8, 28, 6, 0, 0, TimeSpan.Zero);
+ var completed = AzureRunAt(
+ now.AddMinutes(-5),
+ runId: 491,
+ buildNumber: "20260828.3",
+ state: PipelineRunState.Completed,
+ result: PipelineRunResult.Succeeded,
+ finishedAtUtc: now.AddMinutes(-1));
+ var previous = StatusPanelPresentationBuilder.Build(
+ [SnapshotWithAzure(completed, now)],
+ null,
+ now);
+ var azureRow = Assert.Single(previous.Cards[0].BuildSourceRows!);
+ var agedRow = azureRow with { AgeDisplay = "42m · 5m0s" };
+ var agedCard = previous.Cards[0] with { BuildSourceRows = new[] { agedRow } };
+ var current = previous with { Cards = new[] { agedCard } };
+
+ Assert.Equal("42m · 5m0s", agedRow.AgeDisplay);
+ Assert.Equal(azureRow.DeepLinkUrl, agedRow.DeepLinkUrl);
+ Assert.False(StatusPanelPresentationChangeDetector.RequiresUrgentCardRebuild(previous, current));
+ }
+
+ [Fact]
+ public void RequiresUrgentCardRebuild_when_build_source_run_id_changes()
+ {
+ var now = new DateTimeOffset(2026, 8, 28, 6, 0, 0, TimeSpan.Zero);
+ var started = now.AddMinutes(-4);
+ var previous = StatusPanelPresentationBuilder.Build(
+ [SnapshotWithAzure(AzureRunAt(started, runId: 491, buildNumber: "20260828.3"), now)],
+ null,
+ now);
+ var current = StatusPanelPresentationBuilder.Build(
+ [SnapshotWithAzure(AzureRunAt(started, runId: 492, buildNumber: "20260828.4"), now)],
+ null,
+ now);
+
+ Assert.True(StatusPanelPresentationChangeDetector.RequiresUrgentCardRebuild(previous, current));
+ }
+
+ private static AzurePipelineRunInfo AzureRunAt(
+ DateTimeOffset startedUtc,
+ long runId = 491,
+ string buildNumber = "20260828.3",
+ PipelineRunState state = PipelineRunState.InProgress,
+ PipelineRunResult result = PipelineRunResult.Unknown,
+ DateTimeOffset? finishedAtUtc = null) =>
+ new(
+ DefinitionId: 8,
+ PipelineDisplayName: "WitherbyConnect",
+ RunId: runId,
+ BuildNumber: buildNumber,
+ State: state,
+ Result: result,
+ Branch: "PR #185",
+ QueuedAtUtc: startedUtc.AddMinutes(-1),
+ StartedAtUtc: startedUtc,
+ FinishedAtUtc: finishedAtUtc,
+ RunUrl: $"https://dev.azure.com/org/project/_build/results?buildId={runId}&view=results",
+ PullRequestNumber: 185);
+
+ private static AzurePipelineRunInfo AzureRun(
+ long runId = 491,
+ string buildNumber = "20260828.3",
+ PipelineRunState state = PipelineRunState.InProgress) =>
+ AzureRunAt(DateTimeOffset.UtcNow.AddMinutes(-4), runId, buildNumber, state);
+
+ private static ProjectHealthSnapshot SnapshotWithAzure(AzurePipelineRunInfo primary, DateTimeOffset utcNow) =>
+ new(
+ ProjectId: "p1",
+ DisplayName: "Demo",
+ Health: MonitorHealth.Amber,
+ HealthLabel: "Building",
+ State: ProjectLifecycleState.Idle,
+ LastExitCode: 0,
+ LastDuration: null,
+ LastErrorPreview: null,
+ ErrorCount: 0,
+ WarningCount: 0,
+ LastChangedUtc: utcNow,
+ LastBuildFinishedAtUtc: null,
+ IsActive: true,
+ ProgressSteps: [],
+ Azure: new ProjectAzureHealthFacet(
+ AzureMonitoringAvailability.Available,
+ AzureCiMonitoringState.Activity,
+ "master",
+ primary,
+ [],
+ utcNow,
+ HasSelectedPipelines: true));
+
private static ProjectHealthSnapshot Snapshot(
ProjectLifecycleState state,
DateTimeOffset? rebuildQuietUntilUtc = null,
diff --git a/src/Core/Rules/SettingsApplyImpactCatalog.cs b/src/Core/Rules/SettingsApplyImpactCatalog.cs
index 23369e7..c832bd8 100644
--- a/src/Core/Rules/SettingsApplyImpactCatalog.cs
+++ b/src/Core/Rules/SettingsApplyImpactCatalog.cs
@@ -34,30 +34,30 @@ public sealed record Entry(string Path, SettingsApplyImpact Impact, string Ratio
new("Projects[].DisplayName", SettingsApplyImpact.SoftRuntime,
"UI label only."),
new("Projects[].IsActiveInSession", SettingsApplyImpact.HardRestart,
- "Activating/deactivating a Local project must start/stop its runtime. " +
+ "Activating/deactivating a Local project must remount/stop its runtime without compiling. " +
"Azure-only active toggles are applied via SoftRuntime when Local is null " +
"(see classifier Local vs Soft fingerprints)."),
// --- Local attachment (build/run defining) ---
new("Projects[].Local.RootFolder", SettingsApplyImpact.HardRestart,
- "Changes watched/build working directory."),
+ "Changes watched working directory; remount without automatic compile."),
new("Projects[].Local.ProjectFile", SettingsApplyImpact.HardRestart,
- "Changes which project/solution is built."),
+ "Changes which project/solution is monitored; remount without automatic compile."),
new("Projects[].Local.LaunchProfile", SettingsApplyImpact.HardRestart,
- "Changes run environment / launch profile."),
+ "Changes run environment / launch profile; remount process without compile."),
new("Projects[].Local.ExtraDotNetArgs", SettingsApplyImpact.HardRestart,
- "Changes CLI args for build/run."),
+ "Changes CLI args for run/next build; remount process without compile."),
new("Projects[].Local.TestProjectFile", SettingsApplyImpact.SoftRuntime,
"Test target for subsequent RunTests; UpdateDefinition adopts without Local rebuild."),
new("Projects[].Local.StartOnLaunch", SettingsApplyImpact.SoftRuntime,
- "Affects next StartActive only; does not invalidate an already-running runtime."),
+ "Cold BuildMonitor/session startup only; Settings remount never builds from this flag."),
new("Projects[].Local.BuildControlMode", SettingsApplyImpact.SoftRuntime,
"File Watching vs AI Controlled is read live (same as control-plane mode updates)."),
new("Projects[].Local.PreferredSiteUrlScheme", SettingsApplyImpact.SoftRuntime,
"Status URL preference only; no process restart required."),
new("Projects[].Local.RunOptions.RunMode", SettingsApplyImpact.HardRestart,
- "Watch/Run/None changes the live child process model (watch host vs run vs none)."),
+ "Watch/Run/None changes the live child process model; remount without compile."),
new("Projects[].Local.RunOptions.RestartOnCrash", SettingsApplyImpact.SoftRuntime,
"Crash-restart policy is read live from definition on exit."),
new("Projects[].Local.RunOptions.MaxRestartRetries", SettingsApplyImpact.SoftRuntime,
@@ -79,7 +79,7 @@ public sealed record Entry(string Path, SettingsApplyImpact Impact, string Ratio
new("Projects[].Local.RunOptions.AutoRepairCorruptedOutput", SettingsApplyImpact.SoftRuntime,
"Build repair preference; next build path reads definition."),
new("Projects[].Local.RunOptions.WatchExcludeSegments", SettingsApplyImpact.HardRestart,
- "Watcher ignore set is mounted at watcher create; Soft refresh can only add segments, not remount/remove."),
+ "Watcher ignore set remounts without compile; Soft add-only refresh cannot remove segments."),
new("Projects[].Local.RunOptions.AutoOpenLog", SettingsApplyImpact.SoftRuntime,
"UI auto-open preference; App reads settings without restarting Local."),
new("Projects[].Local.RunOptions.ShowStatusPanelWhileBuilding", SettingsApplyImpact.SoftRuntime,
diff --git a/src/Core/Rules/SettingsApplyImpactClassifier.cs b/src/Core/Rules/SettingsApplyImpactClassifier.cs
index 30f9a3f..19b94af 100644
--- a/src/Core/Rules/SettingsApplyImpactClassifier.cs
+++ b/src/Core/Rules/SettingsApplyImpactClassifier.cs
@@ -20,14 +20,14 @@ public enum SettingsApplyImpact
///
/// Monitor, connections, Azure, display names, Local policy/UI prefs (tests, restart flags,
- /// build-control mode, etc.) — refresh orchestrator/Azure without StopAll + StartActive.
+ /// build-control mode, etc.) — refresh orchestrator/Azure without remounting Local process/watcher.
///
SoftRuntime = 2,
///
/// Local process/watcher identity (paths, launch/args, RunMode, watch excludes) or Local
- /// active-session membership — stop/restart Local runtimes (may build via StartAsync when
- /// StartOnLaunch is enabled). There is no restart-without-build apply path yet.
+ /// active-session membership — remount affected Local runtimes without compiling.
+ /// Cold start (before == null) still uses startup build when StartOnLaunch is enabled.
///
HardRestart = 3
}
@@ -35,10 +35,26 @@ public enum SettingsApplyImpact
/// Actions derived from for the tray apply path.
public sealed record SettingsApplyPlan(
SettingsApplyImpact Impact,
- bool StopAllAndRestartActiveProjects,
bool ApplyOrchestratorSettings,
bool ResetHealthTransitionState,
- bool ShowProjectsStartingToast);
+ bool ShowProjectsStartingToast,
+ ///
+ /// Cold BuildMonitor/session start: StopAll + StartActive with startup build when StartOnLaunch.
+ ///
+ bool ColdStartActiveProjectsWithBuild,
+ ///
+ /// Hard Settings Save: remount only — never BuildAsync.
+ ///
+ bool RemountAffectedLocalProjectsWithoutBuild,
+ IReadOnlyList LocalRemounts)
+{
+ ///
+ /// Legacy name used by older tests/docs: true when either cold-start-with-build or
+ /// remount-without-build will touch Local runtimes.
+ ///
+ public bool TouchesLocalRuntimes =>
+ ColdStartActiveProjectsWithBuild || RemountAffectedLocalProjectsWithoutBuild;
+}
///
/// Classifies Settings Save diffs so presentation/Azure/monitor updates are not treated as launch.
@@ -91,28 +107,44 @@ public static SettingsApplyPlan CreatePlan(AppSettings? before, AppSettings afte
{
SettingsApplyImpact.None => new SettingsApplyPlan(
Impact: impact,
- StopAllAndRestartActiveProjects: false,
ApplyOrchestratorSettings: false,
ResetHealthTransitionState: false,
- ShowProjectsStartingToast: false),
+ ShowProjectsStartingToast: false,
+ ColdStartActiveProjectsWithBuild: false,
+ RemountAffectedLocalProjectsWithoutBuild: false,
+ LocalRemounts: []),
SettingsApplyImpact.Presentation => new SettingsApplyPlan(
Impact: impact,
- StopAllAndRestartActiveProjects: false,
ApplyOrchestratorSettings: false,
ResetHealthTransitionState: false,
- ShowProjectsStartingToast: false),
+ ShowProjectsStartingToast: false,
+ ColdStartActiveProjectsWithBuild: false,
+ RemountAffectedLocalProjectsWithoutBuild: false,
+ LocalRemounts: []),
SettingsApplyImpact.SoftRuntime => new SettingsApplyPlan(
Impact: impact,
- StopAllAndRestartActiveProjects: false,
ApplyOrchestratorSettings: true,
ResetHealthTransitionState: false,
- ShowProjectsStartingToast: false),
+ ShowProjectsStartingToast: false,
+ ColdStartActiveProjectsWithBuild: false,
+ RemountAffectedLocalProjectsWithoutBuild: false,
+ LocalRemounts: []),
+ _ when before is null => new SettingsApplyPlan(
+ Impact: SettingsApplyImpact.HardRestart,
+ ApplyOrchestratorSettings: true,
+ ResetHealthTransitionState: true,
+ ShowProjectsStartingToast: true,
+ ColdStartActiveProjectsWithBuild: true,
+ RemountAffectedLocalProjectsWithoutBuild: false,
+ LocalRemounts: []),
_ => new SettingsApplyPlan(
Impact: SettingsApplyImpact.HardRestart,
- StopAllAndRestartActiveProjects: true,
ApplyOrchestratorSettings: true,
ResetHealthTransitionState: true,
- ShowProjectsStartingToast: true)
+ ShowProjectsStartingToast: false,
+ ColdStartActiveProjectsWithBuild: false,
+ RemountAffectedLocalProjectsWithoutBuild: true,
+ LocalRemounts: SettingsLocalRemountPlanner.Plan(before!, after))
};
}
diff --git a/src/Core/Rules/SettingsLocalRemountPlanner.cs b/src/Core/Rules/SettingsLocalRemountPlanner.cs
new file mode 100644
index 0000000..fa5acff
--- /dev/null
+++ b/src/Core/Rules/SettingsLocalRemountPlanner.cs
@@ -0,0 +1,138 @@
+using BuildMonitor.Core.Settings;
+
+namespace BuildMonitor.Core.Rules;
+
+///
+/// How a Local project should be remounted after a HardRestart Settings diff.
+/// Never implies BuildAsync — Settings Save is not a build trigger.
+///
+public enum LocalRemountKind
+{
+ /// No remount work for this id (should not appear in plans).
+ None = 0,
+
+ /// Project deactivated — orchestrator disposes the runtime.
+ StopOnly = 1,
+
+ /// Newly activated — mount watcher only; do not compile or start the app.
+ MountFresh = 2,
+
+ /// Watch ignore / root-adjacent watcher config — recreate watcher; leave process running.
+ WatcherOnly = 3,
+
+ /// Launch profile, extra args, or RunMode — remount watcher and refresh process without build.
+ ProcessAndWatcher = 4,
+
+ /// RootFolder / ProjectFile — remount against new source; do not auto-compile or start process.
+ SourceIdentity = 5
+}
+
+/// Per-project remount instruction derived from a Settings HardRestart diff.
+public sealed record LocalProjectRemountPlan(string ProjectId, LocalRemountKind Kind);
+
+///
+/// Identifies which Local projects a HardRestart Settings save must remount, and how.
+/// Used so unrelated projects are not bounced.
+///
+public static class SettingsLocalRemountPlanner
+{
+ public static IReadOnlyList Plan(AppSettings before, AppSettings after)
+ {
+ ArgumentNullException.ThrowIfNull(before);
+ ArgumentNullException.ThrowIfNull(after);
+
+ var beforeLocal = before.Projects
+ .Where(p => p.Local is not null)
+ .ToDictionary(p => p.Id, StringComparer.Ordinal);
+ var afterLocal = after.Projects
+ .Where(p => p.Local is not null)
+ .ToDictionary(p => p.Id, StringComparer.Ordinal);
+
+ var ids = beforeLocal.Keys
+ .Union(afterLocal.Keys, StringComparer.Ordinal)
+ .OrderBy(id => id, StringComparer.Ordinal);
+
+ var plans = new List();
+ foreach (var id in ids)
+ {
+ beforeLocal.TryGetValue(id, out var b);
+ afterLocal.TryGetValue(id, out var a);
+
+ if (b is null && a is not null)
+ {
+ // New Local id (including Id rename target).
+ if (a.IsActiveInSession)
+ {
+ plans.Add(new LocalProjectRemountPlan(id, LocalRemountKind.MountFresh));
+ }
+
+ continue;
+ }
+
+ if (b is not null && a is null)
+ {
+ // Local id removed (including Id rename source).
+ plans.Add(new LocalProjectRemountPlan(id, LocalRemountKind.StopOnly));
+ continue;
+ }
+
+ if (b is null || a is null)
+ {
+ continue;
+ }
+
+ var wasActive = b.IsActiveInSession;
+ var isActive = a.IsActiveInSession;
+ if (wasActive && !isActive)
+ {
+ plans.Add(new LocalProjectRemountPlan(id, LocalRemountKind.StopOnly));
+ continue;
+ }
+
+ if (!wasActive && isActive)
+ {
+ plans.Add(new LocalProjectRemountPlan(id, LocalRemountKind.MountFresh));
+ continue;
+ }
+
+ if (!wasActive && !isActive)
+ {
+ // Inactive Local hard-field edits do not touch a live runtime.
+ continue;
+ }
+
+ // Both active — classify field deltas.
+ var bl = b.Local!;
+ var al = a.Local!;
+ var sourceChanged =
+ !string.Equals(bl.RootFolder, al.RootFolder, StringComparison.OrdinalIgnoreCase)
+ || !string.Equals(bl.ProjectFile, al.ProjectFile, StringComparison.OrdinalIgnoreCase);
+ if (sourceChanged)
+ {
+ plans.Add(new LocalProjectRemountPlan(id, LocalRemountKind.SourceIdentity));
+ continue;
+ }
+
+ var processChanged =
+ !string.Equals(bl.LaunchProfile, al.LaunchProfile, StringComparison.Ordinal)
+ || !string.Equals(bl.ExtraDotNetArgs, al.ExtraDotNetArgs, StringComparison.Ordinal)
+ || bl.RunOptions.RunMode != al.RunOptions.RunMode;
+ if (processChanged)
+ {
+ plans.Add(new LocalProjectRemountPlan(id, LocalRemountKind.ProcessAndWatcher));
+ continue;
+ }
+
+ var watcherChanged = !string.Equals(
+ bl.RunOptions.WatchExcludeSegments ?? "",
+ al.RunOptions.WatchExcludeSegments ?? "",
+ StringComparison.Ordinal);
+ if (watcherChanged)
+ {
+ plans.Add(new LocalProjectRemountPlan(id, LocalRemountKind.WatcherOnly));
+ }
+ }
+
+ return plans;
+ }
+}
diff --git a/src/Core/Rules/StatusPanelPresentationChangeDetector.cs b/src/Core/Rules/StatusPanelPresentationChangeDetector.cs
index 859f670..19c70e5 100644
--- a/src/Core/Rules/StatusPanelPresentationChangeDetector.cs
+++ b/src/Core/Rules/StatusPanelPresentationChangeDetector.cs
@@ -76,7 +76,7 @@ public static bool RequiresUrgentCardRebuild(
|| prev.ShowProgressChart != card.ShowProgressChart
|| !ProgressStepsEqual(prev.ProgressSteps, card.ProgressSteps)
|| prev.Azure != card.Azure
- || !BuildSourceRowsEqual(prev.BuildSourceRows, card.BuildSourceRows))
+ || !BuildSourceRowsUrgentEqual(prev.BuildSourceRows, card.BuildSourceRows))
{
return true;
}
@@ -85,7 +85,11 @@ public static bool RequiresUrgentCardRebuild(
return previous.Cards.Count != current.Cards.Count;
}
- private static bool BuildSourceRowsEqual(
+ ///
+ /// Urgent BUILDS rebuild when identity, status, or navigation metadata changes — not when only Age ticks.
+ /// Age-only updates defer while the pointer is over the panel so Azure hyperlinks stay clickable.
+ ///
+ private static bool BuildSourceRowsUrgentEqual(
IReadOnlyList? left,
IReadOnlyList? right)
{
@@ -106,7 +110,7 @@ private static bool BuildSourceRowsEqual(
for (var i = 0; i < left.Count; i++)
{
- if (left[i] != right[i])
+ if (!BuildSourceRowUrgentEqual(left[i], right[i]))
{
return false;
}
@@ -115,6 +119,19 @@ private static bool BuildSourceRowsEqual(
return true;
}
+ private static bool BuildSourceRowUrgentEqual(BuildSourcePresentationRow left, BuildSourcePresentationRow right) =>
+ left.Source == right.Source
+ && left.StatusGlyph == right.StatusGlyph
+ && left.StatusText == right.StatusText
+ && left.BranchDisplay == right.BranchDisplay
+ && left.RunDisplay == right.RunDisplay
+ && left.BuildNumberDisplay == right.BuildNumberDisplay
+ && left.PullRequestDisplay == right.PullRequestDisplay
+ && left.IssuesDisplay == right.IssuesDisplay
+ && left.DeepLinkUrl == right.DeepLinkUrl
+ && left.Emphasis == right.Emphasis
+ && left.AttentionNote == right.AttentionNote;
+
private static bool StatusRowsEqual(
IReadOnlyList left,
IReadOnlyList right)
diff --git a/src/Infrastructure/Services/ProjectOrchestrator.cs b/src/Infrastructure/Services/ProjectOrchestrator.cs
index 43722ba..1255564 100644
--- a/src/Infrastructure/Services/ProjectOrchestrator.cs
+++ b/src/Infrastructure/Services/ProjectOrchestrator.cs
@@ -285,6 +285,49 @@ public async Task StartActiveProjectsAsync(CancellationToken cancellationToken)
}
}
+ ///
+ /// Hard Settings Save remount: apply per-project remount kinds without compiling.
+ /// Call after so definitions are current.
+ ///
+ public async Task RemountLocalProjectsWithoutBuildAsync(
+ IReadOnlyList remounts,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(remounts);
+ foreach (var plan in remounts)
+ {
+ if (plan.Kind == LocalRemountKind.StopOnly)
+ {
+ continue; // ApplySettings already disposed inactive runtimes.
+ }
+
+ ProjectRuntime? runtime;
+ lock (sync)
+ {
+ runtimes.TryGetValue(plan.ProjectId, out runtime);
+ }
+
+ if (runtime is null)
+ {
+ continue;
+ }
+
+ try
+ {
+ await runtime.RemountWithoutBuildAsync(plan.Kind, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ RaiseUserNotification(
+ runtime.ProjectId,
+ $"Failed to remount {runtime.DisplayName}",
+ ExceptionDetailFormatter.Format(ex),
+ UserNotificationKind.Error,
+ UserNotificationCategory.Error);
+ }
+ }
+ }
+
private bool ShouldStartOnLaunch(string projectId)
{
var project = settings.Projects.FirstOrDefault(p => p.Id == projectId);
diff --git a/src/Infrastructure/Services/ProjectRuntime.Build.cs b/src/Infrastructure/Services/ProjectRuntime.Build.cs
index e92e513..161bb2b 100644
--- a/src/Infrastructure/Services/ProjectRuntime.Build.cs
+++ b/src/Infrastructure/Services/ProjectRuntime.Build.cs
@@ -24,6 +24,7 @@ private void TryStartFileWatcher()
fileChangeDebounceMs,
GetEffectiveWatchIgnoreSegments());
fileWatcher.Changed += OnFileWatcherChanged;
+ Interlocked.Increment(ref watcherCreateCount);
}
catch (Exception ex)
{
@@ -57,6 +58,7 @@ private void BeginRebuildDisplayReset()
public async Task BuildAsync(CancellationToken cancellationToken)
{
+ Interlocked.Increment(ref buildAsyncInvocationCount);
if (Interlocked.CompareExchange(ref buildInProgress, 1, 0) != 0)
{
// Rejected rebuild: force a user-visible refresh now so the status panel
diff --git a/src/Infrastructure/Services/ProjectRuntime.Run.cs b/src/Infrastructure/Services/ProjectRuntime.Run.cs
index d2bafd9..a844b5b 100644
--- a/src/Infrastructure/Services/ProjectRuntime.Run.cs
+++ b/src/Infrastructure/Services/ProjectRuntime.Run.cs
@@ -18,6 +18,7 @@ internal sealed partial class ProjectRuntime
private void StartRunProcess(bool skipEmbeddedBuild = false)
{
+ Interlocked.Increment(ref processStartCount);
SetProjectCurrentAction(skipEmbeddedBuild
? "Starting app (dotnet run --no-build)"
: "Starting app (dotnet run)");
diff --git a/src/Infrastructure/Services/ProjectRuntime.cs b/src/Infrastructure/Services/ProjectRuntime.cs
index e49c29c..17fa5a2 100644
--- a/src/Infrastructure/Services/ProjectRuntime.cs
+++ b/src/Infrastructure/Services/ProjectRuntime.cs
@@ -85,6 +85,12 @@ internal sealed partial class ProjectRuntime : IDisposable
private int healthDirty;
+ // Lifecycle probes for #90 remount-without-build regression tests.
+ private int buildAsyncInvocationCount;
+ private int remountWithoutBuildCount;
+ private int watcherCreateCount;
+ private int processStartCount;
+
private readonly List registeredWorkerIds = [];
private readonly Dictionary lastWorkerHeartbeatUtc = new(StringComparer.OrdinalIgnoreCase);
@@ -95,6 +101,18 @@ internal sealed partial class ProjectRuntime : IDisposable
public bool IsRunProcessActive => runProcess?.IsRunning == true;
public bool RestartAppAfterRebuild => Local.RunOptions.RestartAppAfterRebuild;
+ /// Test probe: how many times was entered.
+ public int BuildAsyncInvocationCount => Volatile.Read(ref buildAsyncInvocationCount);
+
+ /// Test probe: Settings remount-without-build entries.
+ public int RemountWithoutBuildCount => Volatile.Read(ref remountWithoutBuildCount);
+
+ /// Test probe: file watcher constructions.
+ public int WatcherCreateCount => Volatile.Read(ref watcherCreateCount);
+
+ /// Test probe: supervised process start attempts.
+ public int ProcessStartCount => Volatile.Read(ref processStartCount);
+
public ProjectHealthSnapshot Snapshot => BuildSnapshot();
public ProjectHealthSnapshot BuildSnapshot()
@@ -484,6 +502,88 @@ public async Task StartAsync(CancellationToken cancellationToken)
TryStartFileWatcher();
}
+
+ ///
+ /// Remount watcher/process after a Hard Settings Save. Structurally does not call
+ /// — Settings remount is never a build trigger.
+ ///
+ public async Task RemountWithoutBuildAsync(LocalRemountKind kind, CancellationToken cancellationToken)
+ {
+ if (kind is LocalRemountKind.None or LocalRemountKind.StopOnly)
+ {
+ return;
+ }
+
+ Interlocked.Increment(ref remountWithoutBuildCount);
+ SetProjectCurrentAction("Remounting runtime (no build)");
+
+ switch (kind)
+ {
+ case LocalRemountKind.WatcherOnly:
+ RemountFileWatcherOnly();
+ break;
+
+ case LocalRemountKind.MountFresh:
+ await StopRunProcessAsync(cancellationToken).ConfigureAwait(false);
+ RemountFileWatcherOnly();
+ SetState(fileWatcher is null ? ProjectLifecycleState.Idle : ProjectLifecycleState.Watching);
+ break;
+
+ case LocalRemountKind.SourceIdentity:
+ await StopRunProcessAsync(cancellationToken).ConfigureAwait(false);
+ lastBuildExitCode = -1;
+ lastBuildFinishedAtUtc = null;
+ RemountFileWatcherOnly();
+ SetState(fileWatcher is null ? ProjectLifecycleState.Idle : ProjectLifecycleState.Watching);
+ break;
+
+ case LocalRemountKind.ProcessAndWatcher:
+ await StopRunProcessAsync(cancellationToken).ConfigureAwait(false);
+ RemountFileWatcherOnly();
+ if (Local.RunOptions.RunMode == ProjectRunMode.None)
+ {
+ SetState(fileWatcher is null ? ProjectLifecycleState.Idle : ProjectLifecycleState.Watching);
+ break;
+ }
+
+ if (lastBuildExitCode == 0)
+ {
+ StartRunProcess(skipEmbeddedBuild: true);
+ }
+ else
+ {
+ SetState(fileWatcher is null ? ProjectLifecycleState.Idle : ProjectLifecycleState.Watching);
+ }
+
+ break;
+
+ default:
+ break;
+ }
+
+ RefreshHealth();
+ HealthCoalesceRequested?.Invoke(true);
+ }
+
+ private void RemountFileWatcherOnly()
+ {
+ var wasRunning = runProcess?.IsRunning == true;
+ fileWatcher?.Dispose();
+ fileWatcher = null;
+ TryStartFileWatcher();
+ if (wasRunning && runProcess?.IsRunning == true)
+ {
+ // Process intentionally left running across watcher-only remount.
+ SetState(Local.RunOptions.RunMode == ProjectRunMode.Watch
+ ? ProjectLifecycleState.Watching
+ : ProjectLifecycleState.Running);
+ }
+ else if (runProcess?.IsRunning != true && fileWatcher is not null
+ && Local.RunOptions.RunMode == ProjectRunMode.None)
+ {
+ SetState(ProjectLifecycleState.Watching);
+ }
+ }
private void SetState(ProjectLifecycleState newState)
{
state = newState;
diff --git a/src/TrayApp/App.xaml.cs b/src/TrayApp/App.xaml.cs
index 05ecd2b..8d94703 100644
--- a/src/TrayApp/App.xaml.cs
+++ b/src/TrayApp/App.xaml.cs
@@ -203,7 +203,7 @@ private async Task ApplySettingsAndStartAsync(SettingsApplyPlan plan)
ToastNotificationService.ApplySettings(currentSettings.AppBehavior);
- if (plan.StopAllAndRestartActiveProjects)
+ if (plan.ColdStartActiveProjectsWithBuild)
{
await orchestrator.StopAllAsync();
}
@@ -214,11 +214,17 @@ private async Task ApplySettingsAndStartAsync(SettingsApplyPlan plan)
ApplyControlPlaneHost();
}
- if (plan.StopAllAndRestartActiveProjects
+ if (plan.ColdStartActiveProjectsWithBuild
&& AppLaunchPolicy.ShouldAutoStartAnyProjectsOnLaunch(currentSettings))
{
await orchestrator.StartActiveProjectsAsync(CancellationToken.None).ConfigureAwait(false);
}
+ else if (plan.RemountAffectedLocalProjectsWithoutBuild)
+ {
+ await orchestrator.RemountLocalProjectsWithoutBuildAsync(
+ plan.LocalRemounts,
+ CancellationToken.None).ConfigureAwait(false);
+ }
}
finally
{
@@ -1475,7 +1481,9 @@ private async Task ShowSettingsAsync()
RebuildTrayMenu();
var plan = SettingsApplyImpactClassifier.CreatePlan(previousSettings, currentSettings);
- if (plan.StopAllAndRestartActiveProjects || plan.ApplyOrchestratorSettings)
+ if (plan.ColdStartActiveProjectsWithBuild
+ || plan.RemountAffectedLocalProjectsWithoutBuild
+ || plan.ApplyOrchestratorSettings)
{
var applyVersion = Interlocked.Increment(ref settingsApplyVersion);
_ = ApplySettingsAndStartInBackgroundAsync(applyVersion, plan);
@@ -1498,6 +1506,8 @@ private async Task ShowSettingsAsync()
SettingsApplyImpact.None => "No changes to apply.",
SettingsApplyImpact.Presentation => "Presentation settings updated.",
SettingsApplyImpact.SoftRuntime => "Runtime settings updated without rebuilding.",
+ SettingsApplyImpact.HardRestart =>
+ "Local runtime remounted without rebuilding.",
_ => "Settings updated."
},
ToastKind.Success,