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:
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user