docs: add worker lifecycle implementation plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
829
docs/superpowers/plans/2026-06-01-worker-lifecycle.md
Normal file
829
docs/superpowers/plans/2026-06-01-worker-lifecycle.md
Normal file
@@ -0,0 +1,829 @@
|
||||
# Worker Lifecycle Redesign Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the worker owned by a single external mechanism (a per-user Startup-folder shortcut in production), stop the App from auto-spawning its own worker, and show an actionable prompt when the App can't connect.
|
||||
|
||||
**Architecture:** Installer creates a `.lnk` in the Windows Startup folder instead of a Scheduled Task (migrating existing installs by deleting the old task). The App's `IslandsShellViewModel` drops `EnsureWorkerRunningAsync` and instead runs a one-shot grace timer that opens a `WorkerConnectionModal` (Start Worker / Rerun Installer / Dismiss) if still offline; the footer status pill becomes a button that reopens it.
|
||||
|
||||
**Tech Stack:** .NET 8, WPF installer (COM `IShellLink` for shortcuts), Avalonia + CommunityToolkit.Mvvm UI, xUnit.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Installer (`src/ClaudeDo.Installer`)**
|
||||
- Create: `Core/ShortcutFactory.cs` — shared `IShellLink` COM helper (`CreateShortcut`).
|
||||
- Create: `Core/AutostartShortcut.cs` — install/remove the worker Startup-folder `.lnk`.
|
||||
- Modify: `Steps/CreateShortcutsStep.cs` — use `ShortcutFactory`, drop embedded COM.
|
||||
- Modify: `Steps/RegisterAutostartStep.cs` — Startup shortcut + legacy-task delete (no more task XML).
|
||||
- Modify: `Steps/StartWorkerStep.cs` — `Process.Start` instead of `schtasks /Run`.
|
||||
- Modify: `Steps/StopWorkerStep.cs` — drop `schtasks /End`.
|
||||
- Modify: `Core/UninstallRunner.cs` — remove the Startup `.lnk`.
|
||||
- Delete: `Core/ScheduledTaskXml.cs` (and its test).
|
||||
|
||||
**App (`src/ClaudeDo.Ui`)**
|
||||
- Create: `ViewModels/Modals/WorkerConnectionModalViewModel.cs`.
|
||||
- Create: `Views/Modals/WorkerConnectionModalView.axaml` (+ `.axaml.cs`).
|
||||
- Modify: `ViewModels/IslandsShellViewModel.cs` — remove auto-spawn; add hook, command, grace timer, decision gate.
|
||||
- Modify: `Views/MainWindow.axaml.cs` — wire the new modal.
|
||||
- Modify: `Views/MainWindow.axaml` — clickable status pill.
|
||||
|
||||
**Tests**
|
||||
- Modify: `tests/ClaudeDo.Installer.Tests/` — delete `ScheduledTaskXmlTests.cs`; add `ShortcutFactoryTests.cs`, `AutostartShortcutTests.cs`.
|
||||
- Add: `tests/ClaudeDo.Ui.Tests/ConnectionPromptGateTests.cs`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: ShortcutFactory (shared COM helper)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Installer/Core/ShortcutFactory.cs`
|
||||
- Modify: `src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs`
|
||||
- Test: `tests/ClaudeDo.Installer.Tests/ShortcutFactoryTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
`tests/ClaudeDo.Installer.Tests/ShortcutFactoryTests.cs`:
|
||||
```csharp
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public class ShortcutFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateShortcut_writes_lnk_file()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "cdshortcut-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var target = Path.Combine(dir, "fake.exe");
|
||||
File.WriteAllText(target, "");
|
||||
var lnk = Path.Combine(dir, "x.lnk");
|
||||
|
||||
ShortcutFactory.CreateShortcut(lnk, target, dir, "desc");
|
||||
|
||||
Assert.True(File.Exists(lnk));
|
||||
}
|
||||
finally { Directory.Delete(dir, recursive: true); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter ShortcutFactoryTests`
|
||||
Expected: FAIL — `ShortcutFactory` does not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Create `ShortcutFactory` (move COM interop out of `CreateShortcutsStep`)**
|
||||
|
||||
`src/ClaudeDo.Installer/Core/ShortcutFactory.cs`:
|
||||
```csharp
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.InteropServices.ComTypes;
|
||||
using System.Text;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public static class ShortcutFactory
|
||||
{
|
||||
public static void CreateShortcut(string shortcutPath, string targetPath, string workingDir, string description)
|
||||
{
|
||||
var link = (IShellLink)new ShellLink();
|
||||
link.SetPath(targetPath);
|
||||
link.SetWorkingDirectory(workingDir);
|
||||
link.SetDescription(description);
|
||||
link.SetIconLocation(targetPath, 0);
|
||||
|
||||
var file = (IPersistFile)link;
|
||||
file.Save(shortcutPath, false);
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[Guid("00021401-0000-0000-C000-000000000046")]
|
||||
private class ShellLink { }
|
||||
|
||||
[ComImport]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
[Guid("000214F9-0000-0000-C000-000000000046")]
|
||||
private interface IShellLink
|
||||
{
|
||||
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, IntPtr pfd, int fFlags);
|
||||
void GetIDList(out IntPtr ppidl);
|
||||
void SetIDList(IntPtr pidl);
|
||||
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName);
|
||||
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
|
||||
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath);
|
||||
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
|
||||
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath);
|
||||
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
|
||||
void GetHotkey(out short pwHotkey);
|
||||
void SetHotkey(short wHotkey);
|
||||
void GetShowCmd(out int piShowCmd);
|
||||
void SetShowCmd(int iShowCmd);
|
||||
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon);
|
||||
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
|
||||
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved);
|
||||
void Resolve(IntPtr hwnd, int fFlags);
|
||||
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace the embedded COM in `CreateShortcutsStep` with the helper**
|
||||
|
||||
In `src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs`: delete the private `CreateShortcut` method and the entire `#region COM Interop for IShellLink` block (lines 47-90), remove the now-unused `using System.Runtime.InteropServices;`, `using System.Runtime.InteropServices.ComTypes;`, and `using System.Text;`. Replace the two `CreateShortcut(...)` call sites with `ShortcutFactory.CreateShortcut(...)`:
|
||||
```csharp
|
||||
ShortcutFactory.CreateShortcut(startMenuPath, appExe, workingDir, "ClaudeDo Task Manager");
|
||||
```
|
||||
```csharp
|
||||
ShortcutFactory.CreateShortcut(desktopPath, appExe, workingDir, "ClaudeDo Task Manager");
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter ShortcutFactoryTests`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Installer/Core/ShortcutFactory.cs src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs tests/ClaudeDo.Installer.Tests/ShortcutFactoryTests.cs
|
||||
git commit -m "refactor(installer): extract ShortcutFactory COM helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: AutostartShortcut helper
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Installer/Core/AutostartShortcut.cs`
|
||||
- Test: `tests/ClaudeDo.Installer.Tests/AutostartShortcutTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
`tests/ClaudeDo.Installer.Tests/AutostartShortcutTests.cs`:
|
||||
```csharp
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public class AutostartShortcutTests
|
||||
{
|
||||
private static string TempDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "cdautostart-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Install_creates_lnk_with_expected_name()
|
||||
{
|
||||
var startup = TempDir();
|
||||
var workerDir = TempDir();
|
||||
try
|
||||
{
|
||||
var workerExe = Path.Combine(workerDir, "ClaudeDo.Worker.exe");
|
||||
File.WriteAllText(workerExe, "");
|
||||
|
||||
AutostartShortcut.Install(startup, workerExe);
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(startup, AutostartShortcut.FileName)));
|
||||
}
|
||||
finally { Directory.Delete(startup, true); Directory.Delete(workerDir, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_deletes_existing_lnk()
|
||||
{
|
||||
var startup = TempDir();
|
||||
var workerDir = TempDir();
|
||||
try
|
||||
{
|
||||
var workerExe = Path.Combine(workerDir, "ClaudeDo.Worker.exe");
|
||||
File.WriteAllText(workerExe, "");
|
||||
AutostartShortcut.Install(startup, workerExe);
|
||||
|
||||
AutostartShortcut.Remove(startup);
|
||||
|
||||
Assert.False(File.Exists(Path.Combine(startup, AutostartShortcut.FileName)));
|
||||
}
|
||||
finally { Directory.Delete(startup, true); Directory.Delete(workerDir, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_is_noop_when_missing()
|
||||
{
|
||||
var startup = TempDir();
|
||||
try { AutostartShortcut.Remove(startup); } // must not throw
|
||||
finally { Directory.Delete(startup, true); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter AutostartShortcutTests`
|
||||
Expected: FAIL — `AutostartShortcut` does not exist.
|
||||
|
||||
- [ ] **Step 3: Create `AutostartShortcut`**
|
||||
|
||||
`src/ClaudeDo.Installer/Core/AutostartShortcut.cs`:
|
||||
```csharp
|
||||
using System.IO;
|
||||
|
||||
namespace ClaudeDo.Installer.Core;
|
||||
|
||||
public static class AutostartShortcut
|
||||
{
|
||||
public const string FileName = "ClaudeDo Worker.lnk";
|
||||
|
||||
public static string DefaultStartupDir =>
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.Startup);
|
||||
|
||||
public static string PathIn(string startupDir) => Path.Combine(startupDir, FileName);
|
||||
|
||||
public static void Install(string startupDir, string workerExe)
|
||||
{
|
||||
Directory.CreateDirectory(startupDir);
|
||||
var workingDir = Path.GetDirectoryName(workerExe) ?? startupDir;
|
||||
ShortcutFactory.CreateShortcut(PathIn(startupDir), workerExe, workingDir, "ClaudeDo background worker");
|
||||
}
|
||||
|
||||
public static void Remove(string startupDir)
|
||||
{
|
||||
var path = PathIn(startupDir);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter AutostartShortcutTests`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Installer/Core/AutostartShortcut.cs tests/ClaudeDo.Installer.Tests/AutostartShortcutTests.cs
|
||||
git commit -m "feat(installer): add AutostartShortcut helper for Startup-folder lnk"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: RegisterAutostartStep → Startup shortcut + task migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs`
|
||||
- Delete: `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs`
|
||||
- Delete: `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs`
|
||||
|
||||
- [ ] **Step 1: Replace the step body**
|
||||
|
||||
Rewrite `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs` to:
|
||||
```csharp
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class RegisterAutostartStep : IInstallStep
|
||||
{
|
||||
public const string LegacyTaskName = "ClaudeDoWorker";
|
||||
private const string LegacyServiceName = "ClaudeDoWorker";
|
||||
|
||||
public string Name => "Register Autostart";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
|
||||
if (!File.Exists(workerExe))
|
||||
return StepResult.Fail($"Worker executable not found: {workerExe}");
|
||||
|
||||
// 1) Migrate away the legacy Windows service if present.
|
||||
progress.Report("Checking for legacy worker service...");
|
||||
var (queryExit, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
|
||||
if (queryExit == 0)
|
||||
{
|
||||
progress.Report("Removing legacy worker service...");
|
||||
await ProcessRunner.RunAsync("sc.exe", $"stop {LegacyServiceName}", null, progress, ct);
|
||||
await ProcessRunner.RunAsync("sc.exe", $"delete {LegacyServiceName}", null, progress, ct);
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var (q, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
|
||||
if (q != 0) break;
|
||||
await Task.Delay(1000, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Migrate away the legacy logon scheduled task if present (best-effort).
|
||||
progress.Report("Removing legacy logon task...");
|
||||
await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{LegacyTaskName}\" /F", null, progress, ct);
|
||||
|
||||
// 3) Register per-user autostart via a Startup-folder shortcut.
|
||||
progress.Report("Creating Startup shortcut...");
|
||||
try
|
||||
{
|
||||
AutostartShortcut.Install(AutostartShortcut.DefaultStartupDir, workerExe);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StepResult.Fail($"Failed to create Startup shortcut: {ex.Message}");
|
||||
}
|
||||
|
||||
return StepResult.Ok();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Delete the obsolete scheduled-task code and its test**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git rm src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build the installer to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
|
||||
Expected: Build succeeded. (If `RegisterAutostartStep.TaskName` was referenced elsewhere, the build will flag it — Task 4 and Task 5 update those references; if the build fails only there, proceed to those tasks before re-running.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs
|
||||
git commit -m "feat(installer): register autostart via Startup shortcut, drop scheduled task"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: StartWorkerStep + StopWorkerStep
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Installer/Steps/StartWorkerStep.cs`
|
||||
- Modify: `src/ClaudeDo.Installer/Steps/StopWorkerStep.cs`
|
||||
|
||||
- [ ] **Step 1: Rewrite `StartWorkerStep` to launch the exe directly**
|
||||
|
||||
`src/ClaudeDo.Installer/Steps/StartWorkerStep.cs`:
|
||||
```csharp
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class StartWorkerStep : IInstallStep
|
||||
{
|
||||
public string Name => "Start Worker";
|
||||
|
||||
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
|
||||
if (!File.Exists(workerExe))
|
||||
return Task.FromResult(StepResult.Fail($"Worker executable not found: {workerExe}"));
|
||||
|
||||
progress.Report("Starting worker...");
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(workerExe) { UseShellExecute = true });
|
||||
return Task.FromResult(StepResult.Ok());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(StepResult.Fail($"Failed to start worker: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Drop the `schtasks /End` call in `StopWorkerStep`**
|
||||
|
||||
In `src/ClaudeDo.Installer/Steps/StopWorkerStep.cs`, remove these two lines (the task no longer exists; the process kill below is the real stop):
|
||||
```csharp
|
||||
progress.Report("Stopping worker task (if running)...");
|
||||
await ProcessRunner.RunAsync("schtasks.exe", $"/End /TN \"{TaskName}\"", null, progress, ct);
|
||||
```
|
||||
Keep the `public const string TaskName = "ClaudeDoWorker";` line — `UninstallRunner` still references it for legacy-task cleanup (Task 5). The method keeps its `async` modifier (it still has `await Task.CompletedTask;`).
|
||||
|
||||
- [ ] **Step 3: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Installer/Steps/StartWorkerStep.cs src/ClaudeDo.Installer/Steps/StopWorkerStep.cs
|
||||
git commit -m "feat(installer): start worker via Process.Start, drop schtasks stop"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: UninstallRunner removes the Startup shortcut
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Installer/Core/UninstallRunner.cs`
|
||||
|
||||
- [ ] **Step 1: Add Startup `.lnk` removal**
|
||||
|
||||
In `src/ClaudeDo.Installer/Core/UninstallRunner.cs`, the shortcut-removal block (step 4, around lines 53-60) currently removes the Desktop and Start Menu `.lnk`s. Add the Startup shortcut removal right after them:
|
||||
```csharp
|
||||
// 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest).
|
||||
progress.Report("Removing shortcuts...");
|
||||
TryDeleteFile(Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
|
||||
"ClaudeDo.lnk"));
|
||||
TryDeleteFile(Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
|
||||
"Programs", "ClaudeDo.lnk"));
|
||||
TryDeleteFile(AutostartShortcut.PathIn(AutostartShortcut.DefaultStartupDir));
|
||||
```
|
||||
The existing `schtasks /Delete /TN "{StopWorkerStep.TaskName}" /F` line (step 3) stays — it cleans up the legacy task on machines that still have it.
|
||||
|
||||
- [ ] **Step 2: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Installer/Core/UninstallRunner.cs
|
||||
git commit -m "feat(installer): remove Startup worker shortcut on uninstall"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: App stops auto-spawning the worker
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
|
||||
|
||||
- [ ] **Step 1: Remove the auto-spawn call**
|
||||
|
||||
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, delete this line from the constructor (line 224):
|
||||
```csharp
|
||||
_ = EnsureWorkerRunningAsync();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove the `EnsureWorkerRunningAsync` method and its flag**
|
||||
|
||||
Delete the `_ensureRunningAttempted` field (line 308) and the whole `EnsureWorkerRunningAsync` method (lines 310-320):
|
||||
```csharp
|
||||
private bool _ensureRunningAttempted;
|
||||
|
||||
private async Task EnsureWorkerRunningAsync()
|
||||
{
|
||||
if (_ensureRunningAttempted) return;
|
||||
_ensureRunningAttempted = true;
|
||||
await Task.Delay(TimeSpan.FromSeconds(4));
|
||||
if (Worker?.IsConnected == true) return;
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is null) return;
|
||||
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); }
|
||||
catch { /* logon task is the primary mechanism; this is a convenience */ }
|
||||
}
|
||||
```
|
||||
Keep `RestartWorkerAsync` / `RestartWorkerService` (still used by the existing Restart button). `_workerLocator` stays in use (RestartWorkerService + Task 8).
|
||||
|
||||
- [ ] **Step 3: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||||
Expected: Build succeeded (no remaining references to `EnsureWorkerRunningAsync`).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
|
||||
git commit -m "refactor(ui): stop auto-spawning the worker on app start"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: WorkerConnectionModal (VM + View)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/WorkerConnectionModalViewModel.cs`
|
||||
- Create: `src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml`
|
||||
- Create: `src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml.cs`
|
||||
|
||||
- [ ] **Step 1: Create the ViewModel**
|
||||
|
||||
`src/ClaudeDo.Ui/ViewModels/Modals/WorkerConnectionModalViewModel.cs`:
|
||||
```csharp
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerLocator _workerLocator;
|
||||
private readonly InstallerLocator _installerLocator;
|
||||
|
||||
public WorkerConnectionModalViewModel(WorkerLocator workerLocator, InstallerLocator installerLocator)
|
||||
{
|
||||
_workerLocator = workerLocator;
|
||||
_installerLocator = installerLocator;
|
||||
}
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
[RelayCommand] private void Close() => CloseAction?.Invoke();
|
||||
|
||||
[RelayCommand]
|
||||
private void StartWorker()
|
||||
{
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is null) return;
|
||||
try { Process.Start(new ProcessStartInfo(exe) { UseShellExecute = true }); }
|
||||
catch { /* nothing useful to show */ }
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RerunInstaller()
|
||||
{
|
||||
var path = _installerLocator.Find();
|
||||
if (path is null) return;
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
|
||||
Environment.Exit(0);
|
||||
}
|
||||
catch { /* nothing useful to show */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the View (mirrors `AboutModalView` + `ModalShell`)**
|
||||
|
||||
`src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml`:
|
||||
```xml
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
x:Class="ClaudeDo.Ui.Views.Modals.WorkerConnectionModalView"
|
||||
x:DataType="vm:WorkerConnectionModalViewModel"
|
||||
Title="Worker not reachable"
|
||||
Width="520" Height="240"
|
||||
WindowDecorations="None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
|
||||
<ctl:ModalShell Title="WORKER NOT REACHABLE" CloseCommand="{Binding CloseCommand}">
|
||||
<Grid RowDefinitions="*,Auto" Margin="20,16">
|
||||
<TextBlock Grid.Row="0" Classes="meta" TextWrapping="Wrap"
|
||||
Text="ClaudeDo can't reach the background worker. It is normally started automatically at logon. You can start it now, or reinstall if the problem persists."/>
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8"
|
||||
HorizontalAlignment="Right" Margin="0,16,0,0">
|
||||
<Button Classes="btn" Content="Dismiss" Command="{Binding CloseCommand}"/>
|
||||
<Button Classes="btn" Content="Rerun Installer" Command="{Binding RerunInstallerCommand}"/>
|
||||
<Button Classes="btn primary" Content="Start Worker" Command="{Binding StartWorkerCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ctl:ModalShell>
|
||||
</Window>
|
||||
```
|
||||
|
||||
`src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml.cs`:
|
||||
```csharp
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Modals;
|
||||
|
||||
public partial class WorkerConnectionModalView : Window
|
||||
{
|
||||
public WorkerConnectionModalView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Modals/WorkerConnectionModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml.cs
|
||||
git commit -m "feat(ui): add worker connection help modal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Shell hook, command, grace timer + decision gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/ConnectionPromptGateTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test for the decision gate**
|
||||
|
||||
`tests/ClaudeDo.Ui.Tests/ConnectionPromptGateTests.cs`:
|
||||
```csharp
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests;
|
||||
|
||||
public class ConnectionPromptGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Shows_once_when_offline()
|
||||
{
|
||||
var vm = new IslandsShellViewModel();
|
||||
Assert.True(vm.DecideShowConnectionPrompt(isOffline: true));
|
||||
Assert.False(vm.DecideShowConnectionPrompt(isOffline: true)); // not a second time
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Does_not_show_when_connected_before_grace()
|
||||
{
|
||||
var vm = new IslandsShellViewModel();
|
||||
Assert.False(vm.DecideShowConnectionPrompt(isOffline: false));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter ConnectionPromptGateTests`
|
||||
Expected: FAIL — `DecideShowConnectionPrompt` does not exist.
|
||||
|
||||
- [ ] **Step 3: Add the hook, command, gate, and grace timer**
|
||||
|
||||
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`:
|
||||
|
||||
Add a hook property near the other `Show*Modal` hooks (after line 52):
|
||||
```csharp
|
||||
// Set by MainWindow to open the worker-connection help dialog.
|
||||
public Func<Modals.WorkerConnectionModalViewModel, Task>? ShowWorkerConnectionModal { get; set; }
|
||||
```
|
||||
|
||||
Add the gate field + method and the open command (place near `OpenAbout`, around line 271):
|
||||
```csharp
|
||||
private bool _connectionPromptShown;
|
||||
|
||||
internal bool DecideShowConnectionPrompt(bool isOffline)
|
||||
{
|
||||
if (!isOffline) return false;
|
||||
if (_connectionPromptShown) return false;
|
||||
_connectionPromptShown = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task OpenWorkerConnectionHelpAsync()
|
||||
{
|
||||
var vm = new Modals.WorkerConnectionModalViewModel(_workerLocator, _installerLocator);
|
||||
if (ShowWorkerConnectionModal is not null) await ShowWorkerConnectionModal(vm);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task OpenWorkerConnectionHelp() => OpenWorkerConnectionHelpAsync();
|
||||
```
|
||||
|
||||
Add the grace timer field near `_clearTimer` (line 74):
|
||||
```csharp
|
||||
private readonly System.Timers.Timer _connectTimer = new(12_000) { AutoReset = false };
|
||||
```
|
||||
|
||||
Wire and start it inside the **public** constructor (after the `_primeStatusTimer.Elapsed` wiring, near line 222 — NOT in the parameterless test constructor):
|
||||
```csharp
|
||||
_connectTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (DecideShowConnectionPrompt(IsOffline)) _ = OpenWorkerConnectionHelpAsync();
|
||||
});
|
||||
_connectTimer.Start();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter ConnectionPromptGateTests`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Build the app**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs tests/ClaudeDo.Ui.Tests/ConnectionPromptGateTests.cs
|
||||
git commit -m "feat(ui): prompt once on worker connection failure with grace timer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Wire the modal in MainWindow + clickable status pill
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`
|
||||
|
||||
- [ ] **Step 1: Wire the dialog hook**
|
||||
|
||||
In `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`, inside `OnDataContextChanged`, after the existing `vm.ShowRepoImportModal = ...` block (line 70), add:
|
||||
```csharp
|
||||
vm.ShowWorkerConnectionModal = async (connVm) =>
|
||||
{
|
||||
var dlg = new WorkerConnectionModalView { DataContext = connVm };
|
||||
connVm.CloseAction = () => dlg.Close();
|
||||
await dlg.ShowDialog(this);
|
||||
};
|
||||
```
|
||||
(`ClaudeDo.Ui.Views.Modals` is already imported at line 10.)
|
||||
|
||||
- [ ] **Step 2: Make the status pill a button**
|
||||
|
||||
In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, replace the left "connection pill" `StackPanel` (lines 190-202) with a `Button` wrapping the same content:
|
||||
```xml
|
||||
<!-- Left: connection pill (click to open worker help) -->
|
||||
<Button DockPanel.Dock="Left"
|
||||
Command="{Binding OpenWorkerConnectionHelpCommand}"
|
||||
Background="Transparent" BorderThickness="0" Padding="0"
|
||||
Cursor="Hand" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<Ellipse Width="7" Height="7" Fill="{DynamicResource StatusRunningBrush}"
|
||||
IsVisible="{Binding Worker.IsConnected}"/>
|
||||
<Ellipse Width="7" Height="7" Fill="{DynamicResource StatusReviewBrush}"
|
||||
IsVisible="{Binding Worker.IsReconnecting}"/>
|
||||
<Ellipse Width="7" Height="7" Fill="{DynamicResource StatusErrorBrush}"
|
||||
IsVisible="{Binding IsOffline}"/>
|
||||
<TextBlock Classes="eyebrow"
|
||||
Text="{Binding ConnectionText, Converter={StaticResource UpperCase}}"
|
||||
LetterSpacing="1.4"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build the app**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 4: Manual verification**
|
||||
|
||||
Start the worker (or leave it stopped) and run the App:
|
||||
- Worker stopped → after ~12s the "WORKER NOT REACHABLE" dialog appears once. **Start Worker** launches it (footer pill turns ONLINE); **Rerun Installer** launches the installer and exits; **Dismiss** closes and does not reappear automatically.
|
||||
- Click the footer status pill anytime → the dialog reopens.
|
||||
- Worker running before launch → no dialog appears.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Views/MainWindow.axaml.cs src/ClaudeDo.Ui/Views/MainWindow.axaml
|
||||
git commit -m "feat(ui): wire worker connection modal and make status pill clickable"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Full build + test sweep
|
||||
|
||||
- [ ] **Step 1: Build the touched projects**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
|
||||
dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
|
||||
```
|
||||
Expected: both Build succeeded.
|
||||
|
||||
- [ ] **Step 2: Run the affected test suites**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
dotnet test tests/ClaudeDo.Installer.Tests
|
||||
dotnet test tests/ClaudeDo.Ui.Tests
|
||||
```
|
||||
Expected: all pass; no references to the deleted `ScheduledTaskXml`.
|
||||
|
||||
- [ ] **Step 3: Final commit (if any stragglers)**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: worker lifecycle redesign cleanup" || echo "nothing to commit"
|
||||
```
|
||||
Reference in New Issue
Block a user