refactor(ui): remove the stale-worker banner

The banner compared the running worker's build SHA against the
selected list's HEAD via a git ancestor check on every list switch
and reconnect. It added a git-rev-parse MSBuild target, a hub call,
and a poll loop for a corner case that hasn't come up in practice —
not worth the moving parts.
This commit is contained in:
mika kuns
2026-09-02 09:59:39 +02:00
parent b6e5876b07
commit 6be62f02cc
13 changed files with 1 additions and 221 deletions
-2
View File
@@ -13,8 +13,6 @@ namespace ClaudeDo.Data.Wire;
public record ActiveTaskDto(string Slot, string TaskId, DateTime StartedAt);
public record WorkerBuildInfoDto(string? BuildSha);
public record AppSettingsDto(
string DefaultClaudeInstructions,
string DefaultModel,
@@ -720,9 +720,6 @@
"updateNow": "Jetzt aktualisieren",
"dismiss": "Ausblenden"
},
"staleWorker": {
"message": "Der Worker läuft auf einem älteren Stand als der gemergte main-Branch dieses Repos — Neustart nötig, damit gemergte Änderungen wirken."
},
"externalMerge": {
"banner": "Eine Claude-Session löst gerade einen Merge-Konflikt auf — bitte keine Dateien im Repository bearbeiten.",
"open": "Resolver öffnen"
@@ -720,9 +720,6 @@
"updateNow": "Update now",
"dismiss": "Dismiss"
},
"staleWorker": {
"message": "The worker is running an older build than this repo's merged main — restart it so your merged changes take effect."
},
"externalMerge": {
"banner": "A Claude session is resolving a merge conflict — don't edit files in the repository.",
"open": "Open resolver"
@@ -183,9 +183,6 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<string> GetLastPrepLogAsync();
Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync();
/// <summary>Git SHA the running worker was built from (null when offline or the build isn't stamped).</summary>
Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync();
Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync();
Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto);
Task DeletePrimeScheduleAsync(Guid id);
-3
View File
@@ -492,9 +492,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<string> GetLastPrepLogAsync()
=> await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty;
public Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync()
=> TryInvokeAsync<WorkerBuildInfoDto>("GetWorkerBuildInfo");
public async Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync()
=> await TryInvokeAsync<List<WorkerLogEntry>>("GetRecentLogs") ?? new List<WorkerLogEntry>();
@@ -5,7 +5,6 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
@@ -52,7 +51,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
private readonly UpdateCheckService _updateCheck = null!;
private readonly InstallerLocator _installerLocator = null!;
private readonly WorkerLocator _workerLocator = null!;
private readonly GitService? _git;
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
private readonly Func<WeeklyReportModalViewModel> _weeklyReportVmFactory = () => null!;
@@ -121,11 +119,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
[ObservableProperty] private string? _updateBannerLatestVersion;
private bool _bannerDismissedThisSession;
// Persistent (not auto-clearing) banner: the running worker predates the selected list's
// merged HEAD, so "verified" claims made against the current process are stale. ClaudeDo-repo
// only — see RefreshStaleWorkerCheckAsync.
[ObservableProperty] private bool _isStaleWorkerBannerVisible;
// planningTaskId -> subtaskId, for unit-merge conflicts an MCP session (not the UI) started.
// Kept as a dictionary so a later conflict on the same planning task updates in place instead
// of piling up duplicate entries.
@@ -328,8 +321,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
Func<MergeModalViewModel> mergeVmFactory,
Func<RepoImportModalViewModel> repoImportVmFactory,
MissionControlViewModel missionControl,
UsagePillViewModel usagePill,
GitService? git = null)
UsagePillViewModel usagePill)
{
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
MissionControl = missionControl;
@@ -349,9 +341,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_usageMonitorVmFactory = usageMonitorVmFactory;
_mergeVmFactory = mergeVmFactory;
_repoImportVmFactory = repoImportVmFactory;
_git = git;
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync();
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask, Tasks.SelectionSource);
Tasks.PrepRequested += () => Details.ShowPrep();
Tasks.PropertyChanged += (_, e) =>
@@ -418,7 +408,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
};
Worker.OperationProgressEvent += OnOperationProgress;
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
Worker.ConnectionRestoredEvent += () => _ = RefreshStaleWorkerCheckAsync();
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
Worker.PlanningMergeAbortedEvent += OnPlanningMergeAborted;
Worker.PlanningCompletedEvent += OnPlanningMergeCompleted;
@@ -471,8 +460,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_connectTimer.Dispose();
_primeStatusTimer.Stop();
_primeStatusTimer.Dispose();
_staleWorkerCts?.Cancel();
_staleWorkerCts?.Dispose();
}
private void RefreshBannerFromStatus()
@@ -492,57 +479,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
}
}
private CancellationTokenSource? _staleWorkerCts;
// Re-evaluates the stale-worker banner for the currently selected list. Cheap (one hub call +
// up to two git subprocesses) and only ever runs for a git-backed list, so it's fine to fire on
// every selection change / reconnect rather than caching.
private async Task RefreshStaleWorkerCheckAsync()
{
_staleWorkerCts?.Cancel();
var cts = new CancellationTokenSource();
_staleWorkerCts = cts;
var stale = await ComputeIsStaleWorkerAsync(Lists?.SelectedList?.WorkingDir, cts.Token);
if (cts.IsCancellationRequested) return;
IsStaleWorkerBannerVisible = stale;
}
private async Task<bool> ComputeIsStaleWorkerAsync(string? workingDir, CancellationToken ct)
{
if (_git is null || Worker is null || string.IsNullOrWhiteSpace(workingDir)) return false;
try
{
var buildInfo = await Worker.GetWorkerBuildInfoAsync();
var buildSha = buildInfo?.BuildSha;
if (string.IsNullOrWhiteSpace(buildSha)) return false;
if (!await _git.IsGitRepoAsync(workingDir, ct)) return false;
var head = await _git.RevParseHeadAsync(workingDir, ct);
var isAncestor = string.Equals(buildSha, head, StringComparison.OrdinalIgnoreCase)
? (bool?)false // equal — never "stale" on a match, and no need to ask git
: await _git.IsAncestorAsync(workingDir, buildSha, head, ct);
return ShouldShowStaleWorkerBanner(buildSha, head, isAncestor);
}
catch
{
// Worker offline, dir no longer a repo, etc. — unknown, so stay quiet.
return false;
}
}
// Pure decision extracted for testability. isAncestor is the tri-state result of
// `git merge-base --is-ancestor buildSha head`: true = worker predates head (stale), false =
// equal or diverged (never claim "stale" on a match or an unrelated history), null = unknown
// (e.g. buildSha isn't a commit this repo knows about — never treat "unknown" as "stale").
internal static bool ShouldShowStaleWorkerBanner(string? buildSha, string? headSha, bool? isAncestor)
{
if (string.IsNullOrWhiteSpace(buildSha) || string.IsNullOrWhiteSpace(headSha)) return false;
if (string.Equals(buildSha, headSha, StringComparison.OrdinalIgnoreCase)) return false;
return isAncestor == true;
}
[RelayCommand]
private void OpenMissionControl()
{
-23
View File
@@ -259,29 +259,6 @@
</StackPanel>
</Border>
<!-- Stale-worker notice — bottom-right corner, above the update popup. Persistent
(no auto-dismiss timer): the running worker predates the selected list's merged
HEAD, so a restart is needed before "verified" claims against it are trustworthy. -->
<Border Grid.Row="0" Grid.RowSpan="3"
VerticalAlignment="Bottom" HorizontalAlignment="Right"
Margin="0,0,16,100"
ZIndex="100"
IsVisible="{Binding IsStaleWorkerBannerVisible}"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="6"
Padding="14,10">
<StackPanel Spacing="8" Width="260">
<TextBlock Classes="body" Text="{loc:Tr shell.staleWorker.message}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Classes="btn"
Content="{loc:Tr shell.menu.restartWorker}"
Command="{Binding RestartWorkerCommand}"/>
</StackPanel>
</StackPanel>
</Border>
<!-- External merge popup card — bottom-left corner, while an MCP session owns an
unresolved unit-merge conflict. Does not auto-dismiss; stays until the merge ends. -->
<Border Grid.Row="0" Grid.RowSpan="3"
@@ -35,18 +35,4 @@
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
</ItemGroup>
<!-- Stamps this worker's exact commit into AssemblyInformationalVersion as "+{sha}" so a
running instance can report which commit it was built from (see WorkerHub.GetWorkerBuildInfo).
Independent of MinVer: MinVerVersionOverride (release builds) replaces the base version but
doesn't touch SourceRevisionId, and the SDK appends it to InformationalVersion regardless. -->
<Target Name="SetBuildRevisionFromGit" BeforeTargets="GetAssemblyVersion" Condition="'$(SourceRevisionId)' == ''">
<Exec Command="git rev-parse HEAD"
WorkingDirectory="$(MSBuildProjectDirectory)"
ConsoleToMSBuild="true"
StandardOutputImportance="low"
ContinueOnError="WarnAndContinue">
<Output TaskParameter="ConsoleOutput" PropertyName="SourceRevisionId" />
</Exec>
</Target>
</Project>
-19
View File
@@ -35,20 +35,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private static readonly string Version =
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
// SourceRevisionId (set by ClaudeDo.Worker.csproj's git-rev-parse build target) is appended
// by the SDK to InformationalVersion as "+{sha}" -- take the segment after the LAST '+' so
// this stays correct even when MinVer's own pre-release metadata already contains a '+'.
private static readonly string? BuildSha = ParseBuildSha(
Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion);
internal static string? ParseBuildSha(string? informationalVersion)
{
if (string.IsNullOrEmpty(informationalVersion)) return null;
var plusIndex = informationalVersion.LastIndexOf('+');
if (plusIndex < 0 || plusIndex == informationalVersion.Length - 1) return null;
return informationalVersion[(plusIndex + 1)..];
}
private readonly QueueService _queue;
private readonly IQueueWaker _waker;
private readonly AgentFileService _agentService;
@@ -233,11 +219,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
public string Ping() => $"pong v{Version}";
/// <summary>Lets the UI detect a worker that's still running an older build than the code it's
/// comparing against (e.g. a just-merged main) — a separate diagnostic rather than extending
/// Ping's "pong vX.Y.Z" text, since that format isn't ours to break for any existing caller.</summary>
public WorkerBuildInfoDto GetWorkerBuildInfo() => new(BuildSha);
public IReadOnlyList<ActiveTaskDto> GetActive()
{
return _queue.GetActive()
@@ -1,47 +0,0 @@
using ClaudeDo.Ui.ViewModels;
using Xunit;
namespace ClaudeDo.Ui.Tests;
public class StaleWorkerBannerDecisionTests
{
private const string ShaA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
private const string ShaB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
[Fact]
public void True_when_build_is_a_strict_ancestor_of_head()
{
Assert.True(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, ShaB, isAncestor: true));
}
[Fact]
public void False_when_build_equals_head_even_if_ancestor_check_says_true()
{
Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, ShaA, isAncestor: true));
}
[Fact]
public void False_when_ancestor_check_says_no()
{
Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, ShaB, isAncestor: false));
}
[Fact]
public void False_when_ancestor_check_is_unknown()
{
// Unknown must never be treated as stale.
Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, ShaB, isAncestor: null));
}
[Fact]
public void False_when_build_sha_missing()
{
Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(null, ShaB, isAncestor: true));
}
[Fact]
public void False_when_head_sha_missing()
{
Assert.False(IslandsShellViewModel.ShouldShowStaleWorkerBanner(ShaA, null, isAncestor: true));
}
}
@@ -170,8 +170,6 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
public string LastPrepLog = "";
public virtual Task<string> GetLastPrepLogAsync() => Task.FromResult(LastPrepLog);
public WorkerBuildInfoDto? WorkerBuildInfo;
public virtual Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync() => Task.FromResult(WorkerBuildInfo);
public virtual Task RefineTaskAsync(string taskId) => Task.CompletedTask;
public virtual Task<ClaudeBinDto?> GetClaudeBinAsync() => Task.FromResult<ClaudeBinDto?>(null);
@@ -1,36 +0,0 @@
using ClaudeDo.Worker.Hub;
using Xunit;
namespace ClaudeDo.Worker.Tests.Hub;
public sealed class WorkerBuildInfoHubTests
{
[Theory]
[InlineData(null, null)]
[InlineData("", null)]
[InlineData("1.2.3", null)]
[InlineData("1.2.3+", null)]
[InlineData("1.2.3+abc1234", "abc1234")]
// MinVer's own pre-release metadata can already contain a '+' (e.g. "+devsha"); our
// SourceRevisionId is appended last, so only the segment after the LAST '+' is authoritative.
[InlineData("0.0.0-alpha.0.4+devsha+f5b1a2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9", "f5b1a2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9")]
public void ParseBuildSha_ExtractsSegmentAfterLastPlus(string? informationalVersion, string? expected)
{
Assert.Equal(expected, WorkerHub.ParseBuildSha(informationalVersion));
}
[Fact]
public void GetWorkerBuildInfo_ReturnsDto()
{
var hub = new WorkerHub(
null!, null!, null!, null!, null!, null!,
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
// No assertion on the actual SHA value (depends on the build environment) — just that
// the call doesn't throw and always returns a DTO, never null.
var info = hub.GetWorkerBuildInfo();
Assert.NotNull(info);
}
}
@@ -159,7 +159,6 @@ sealed class FakeWorkerClient : IWorkerClient
public Task UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask;
public Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
public Task<string> GetLastPrepLogAsync() => Task.FromResult(string.Empty);
public Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync() => Task.FromResult<WorkerBuildInfoDto?>(null);
public Task RefineTaskAsync(string taskId) => Task.CompletedTask;
public Task<ClaudeBinDto?> GetClaudeBinAsync() => Task.FromResult<ClaudeBinDto?>(null);
public Task<ClaudeBinDto?> SetClaudeBinAsync(string? value) => Task.FromResult<ClaudeBinDto?>(null);