10 Commits

Author SHA1 Message Date
Mika Kuns
3b1f148122 feat(branding): add app and installer icons
All checks were successful
Release / release (push) Successful in 27s
- app: ClaudeTask.ico wired as ApplicationIcon and MainWindow.Icon
  via avares URI
- installer: ClaudeTaskSetup.ico wired as ApplicationIcon and embedded
  as a WPF Resource so WizardWindow and SettingsWindow can reference it
  via pack URI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:28:15 +02:00
Mika Kuns
2b3fe02d8c fix(ui): prevent async void races and leak-on-exit
- task detail vm: LoadAsync now uses a per-call CancellationTokenSource
  so rapid TaskUpdated events can't race on _taskId / Subtasks / Tags;
  old subtask PropertyChanged handlers are torn down before Clear
- task detail vm: async void event handlers (OnTaskUpdated,
  OnWorktreeUpdated, OnSubtaskPropertyChanged) wrap work in try/catch
  so thrown exceptions can't crash the Avalonia sync context
- task detail vm: Clear cancels/disposes the load CTS so a late-arriving
  LoadAsync can't resurrect detail state after deselect
- app: DisposeAsync the ServiceProvider in a finally after the classic
  desktop lifetime ends, so WorkerClient.DisposeAsync runs and the
  SignalR connection closes cleanly instead of being abandoned

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:27:45 +02:00
Mika Kuns
d3b85f2234 fix(worker): address concurrency, cancellation, and resource issues
- claude process: run stdout/stderr reads without ct; rely on
  kill-on-cancel closing the pipes to unblock them — previously
  ReadLineAsync(ct) could hang, stalling task slots and shutdown
- task runner: terminal db writes (task_runs, MarkDone, MarkFailed,
  SetLogPath) now use CancellationToken.None; RunOnceAsync catches
  OCE and finalizes the run row so ContinueAsync can resume
- task repository: GetNextQueuedAgentTaskAsync is now a single
  UPDATE ... RETURNING statement — closes TOCTOU window where two
  loop iterations could dispatch the same queued task
- queue service: dispose CancellationTokenSource in slot-completion
  ContinueWith to stop leaking wait handles
- git service: register ct.Kill(processTree), drain reads without ct,
  always reap via WaitForExitAsync(None) — no more git zombies on
  cancelled worktree ops
- worktree manager: branch name uses full task id (dashes stripped)
  instead of 8-char prefix, eliminating collision risk

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:27:18 +02:00
Mika Kuns
fc9029de97 fix(installer): wait for prior service registration to clear before create
After `sc delete`, the service stays in "marked for deletion" state until
every open handle (services.msc, Task Manager Services tab, Event Viewer,
prior sc query process) is closed. The installer used to immediately call
`sc create` and hit a silent hang / confusing "specified service has been
marked for deletion" error.

Poll `sc query` for up to 30s after delete; if the service is still
registered past that, fail with actionable guidance (close the offending
console or reboot). Also translate exit 1072 from `sc create` into the
same human-readable hint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:54:36 +02:00
Mika Kuns
1c764dae3f fix(installer): publish framework-dependent single-file
All checks were successful
Release / release (push) Successful in 27s
Self-contained single-file bundle was crashing at startup with 0xc0000005
in the apphost bootstrap (COR_E_EXECUTIONENGINE), because the Linux Gitea
runner doesn't carry the Microsoft.WindowsDesktop.App runtime pack — the
resulting bundle was missing WPF runtime bits. Disabling compression alone
didn't resolve it.

Switch to framework-dependent single-file: target machines need the .NET 8
Desktop Runtime (x64) installed but the bundle is ~2 MB instead of ~140 MB
and starts reliably. Keep IncludeAllContentForSelfExtract=true so native
deps (e_sqlite3) extract to a temp dir on first run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:11:27 +02:00
Mika Kuns
cfec3297a4 fix(installer): disable single-file compression to prevent WPF startup AV
All checks were successful
Release / release (push) Successful in 30s
Published installer was crashing at launch with 0xc0000005 (access violation)
and exit code 0x80131506 (COR_E_EXECUTIONENGINE), faulting inside the exe's
own bundle-extractor bootstrap before any managed code ran. This is a known
interaction between WPF, single-file publish, and EnableCompressionInSingleFile.

Disable compression (exe grows ~30% but startup becomes reliable).
IncludeAllContentForSelfExtract stays true — WPF still needs on-disk
extraction for XAML resources.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:48:06 +02:00
Mika Kuns
6e1d64b489 Change sdk Version
All checks were successful
Release / release (push) Successful in 39s
2026-04-15 14:23:26 +02:00
Mika Kuns
f599f8d0af fix(installer,worker): service hosting, dark theme, uninstall polish
Some checks failed
Release / release (push) Failing after 0s
Worker:
- Wire UseWindowsService + Microsoft.Extensions.Hosting.WindowsServices so
  SCM's Service Control Protocol handshake succeeds. Previously the binary
  exited immediately under sc start, leaving the service registered but
  never running.

Installer:
- Pin SDK to .NET 9 (global.json) — SDK 10 dropped win-arm from its RID
  graph, breaking restore of the WPF project; .NET 9 keeps win-arm AND
  understands the .slnx solution format.
- Force SelfContained=true and default RID=win-x64 when PublishSingleFile
  is set, so Rider Publish and CLI produce the same bundle.
- Dark theme: set Background/Foreground explicitly on WizardWindow and
  SettingsWindow roots (WPF implicit styles don't cascade to derived
  Window types). Custom ComboBox template + ComboBoxItem style so
  dropdowns honour the dark palette instead of system defaults.
- Throttle download progress to one report per MB and overwrite the same
  UI line (\r prefix marker) instead of appending per chunk.
- Register ClaudeDo in HKLM\...\Uninstall so it appears in Apps & Features.
  Copy installer into InstallDir\uninstaller\ for the UninstallString, and
  schedule a cmd.exe trampoline to handle the self-delete case when
  Apps & Features launches the copy from inside the install dir.
- Treat sc.exe stop exit 1062 (ERROR_SERVICE_NOT_ACTIVE) as success.
- Delete the uninstall registry key during UninstallRunner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:19:09 +02:00
Mika Kuns
9b928c6217 fix(installer): set EnableWindowsTargeting so Linux Gitea runners can publish
All checks were successful
Release / release (push) Successful in 40s
The release workflow runs on a Linux container; building net8.0-windows +
UseWPF=true requires this opt-in property since .NET 8. No-op on Windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:39:23 +02:00
c9e38aef88 Merge pull request 'feat/ui-improvements' (#1) from feat/ui-improvements into main
Some checks failed
Release / release (push) Failing after 19s
Reviewed-on: #1
2026-04-15 09:28:58 +00:00
29 changed files with 585 additions and 129 deletions

View File

@@ -67,7 +67,7 @@ jobs:
-c Release -r win-x64 --self-contained true \ -c Release -r win-x64 --self-contained true \
/p:Version=$VERSION -o out/worker /p:Version=$VERSION -o out/worker
- name: Publish ClaudeDo.Installer (win-x64, single-file) - name: Publish ClaudeDo.Installer (win-x64, single-file, framework-dependent)
env: env:
WORK: ${{ steps.ws.outputs.dir }} WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }} VERSION: ${{ steps.ver.outputs.version }}
@@ -75,8 +75,11 @@ jobs:
set -euo pipefail set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH" export PATH="$DOTNET_ROOT:$PATH"
cd "$WORK/src" cd "$WORK/src"
# Framework-dependent — WPF runtime pack isn't distributed on Linux SDK;
# the previous self-contained bundle crashed at startup (apphost AV).
# Target machines need .NET 8 Desktop Runtime (x64).
dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \ dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
-c Release -r win-x64 --self-contained true \ -c Release -r win-x64 --self-contained false \
/p:Version=$VERSION /p:PublishSingleFile=true \ /p:Version=$VERSION /p:PublishSingleFile=true \
-o out/installer -o out/installer

6
global.json Normal file
View File

@@ -0,0 +1,6 @@
{
"sdk": {
"version": "8.0.418",
"rollForward": "latestFeature"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 B

View File

@@ -4,6 +4,7 @@
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\ClaudeTask.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup> </PropertyGroup>

View File

@@ -22,8 +22,19 @@ sealed class Program
var factory = services.GetRequiredService<SqliteConnectionFactory>(); var factory = services.GetRequiredService<SqliteConnectionFactory>();
SchemaInitializer.Apply(factory); SchemaInitializer.Apply(factory);
BuildAvaloniaApp() try
.StartWithClassicDesktopLifetime(args); {
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
finally
{
// Dispose the container so WorkerClient.DisposeAsync runs —
// cancels the retry loop and closes the SignalR connection cleanly
// instead of abandoning it.
try { services.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
catch { /* best effort on shutdown */ }
}
} }
public static AppBuilder BuildAvaloniaApp() public static AppBuilder BuildAvaloniaApp()

View File

@@ -104,20 +104,34 @@ public sealed class GitService
using var proc = new Process { StartInfo = psi }; using var proc = new Process { StartInfo = psi };
proc.Start(); proc.Start();
// On cancellation: kill the git process tree. Killing closes the
// redirected pipes, which unblocks the ReadToEndAsync calls below
// and lets WaitForExitAsync return so the process is reaped.
// Without this, cancelling mid-git leaves zombie processes.
await using var ctr = ct.Register(() =>
{
try { proc.Kill(entireProcessTree: true); }
catch { /* already exited */ }
});
if (stdinData is not null) if (stdinData is not null)
{ {
await proc.StandardInput.WriteAsync(stdinData.AsMemory(), ct); await proc.StandardInput.WriteAsync(stdinData.AsMemory(), ct);
proc.StandardInput.Close(); proc.StandardInput.Close();
} }
var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct); // Drain output without ct — pipes close when the process exits
var stderrTask = proc.StandardError.ReadToEndAsync(ct); // (whether naturally or via Kill above), so these always complete.
var stdoutTask = proc.StandardOutput.ReadToEndAsync();
var stderrTask = proc.StandardError.ReadToEndAsync();
await proc.WaitForExitAsync(ct); await proc.WaitForExitAsync(CancellationToken.None);
var stdout = await stdoutTask; var stdout = await stdoutTask;
var stderr = await stderrTask; var stderr = await stderrTask;
ct.ThrowIfCancellationRequested();
return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd()); return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd());
} }
} }

View File

@@ -174,26 +174,36 @@ public sealed class TaskRepository
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default) public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
{ {
// Atomically claim the next queued agent task: the UPDATE flips its
// status to 'running' in the same statement that returns its row,
// eliminating the TOCTOU gap where two queue-loop iterations could
// both select the same queued task before either marked it running.
// The caller is responsible for populating started_at shortly after.
await using var conn = _factory.Open(); await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand(); await using var cmd = conn.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
SELECT t.id, t.list_id, t.title, t.description, t.status, t.scheduled_for, UPDATE tasks
t.result, t.log_path, t.created_at, t.started_at, t.finished_at, t.commit_type, SET status = 'running'
t.model, t.system_prompt, t.agent_path WHERE id = (
FROM tasks t SELECT t.id
WHERE t.status = 'queued' FROM tasks t
AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now) WHERE t.status = 'queued'
AND EXISTS ( AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now)
SELECT 1 FROM task_tags tt AND EXISTS (
JOIN tags tg ON tg.id = tt.tag_id SELECT 1 FROM task_tags tt
WHERE tt.task_id = t.id AND tg.name = 'agent' JOIN tags tg ON tg.id = tt.tag_id
UNION WHERE tt.task_id = t.id AND tg.name = 'agent'
SELECT 1 FROM list_tags lt UNION
JOIN tags tg ON tg.id = lt.tag_id SELECT 1 FROM list_tags lt
WHERE lt.list_id = t.list_id AND tg.name = 'agent' JOIN tags tg ON tg.id = lt.tag_id
) WHERE lt.list_id = t.list_id AND tg.name = 'agent'
ORDER BY t.created_at ASC )
LIMIT 1 ORDER BY t.created_at ASC
LIMIT 1
)
RETURNING id, list_id, title, description, status, scheduled_for,
result, log_path, created_at, started_at, finished_at, commit_type,
model, system_prompt, agent_path
"""; """;
cmd.Parameters.AddWithValue("@now", now.ToString("o")); cmd.Parameters.AddWithValue("@now", now.ToString("o"));

View File

@@ -111,6 +111,7 @@ public partial class App : Application
sc.AddSingleton<IInstallStep, InitDatabaseStep>(); sc.AddSingleton<IInstallStep, InitDatabaseStep>();
sc.AddSingleton<IInstallStep, RegisterServiceStep>(); sc.AddSingleton<IInstallStep, RegisterServiceStep>();
sc.AddSingleton<IInstallStep, CreateShortcutsStep>(); sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>();
sc.AddSingleton<WriteInstallManifestStep>(); sc.AddSingleton<WriteInstallManifestStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>()); sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());

View File

@@ -6,8 +6,16 @@
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<!-- Allow Linux Gitea runners to publish this WPF project for win-x64; no-op on Windows. -->
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<ApplicationIcon>ClaudeTaskSetup.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<!-- Embed icon so it is available via pack URI in WPF windows. -->
<Resource Include="ClaudeTaskSetup.ico" />
</ItemGroup>
<!-- Debug: asInvoker so Rider/VS can debug without elevation --> <!-- Debug: asInvoker so Rider/VS can debug without elevation -->
<PropertyGroup Condition="'$(Configuration)' == 'Debug'"> <PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<ApplicationManifest>app.debug.manifest</ApplicationManifest> <ApplicationManifest>app.debug.manifest</ApplicationManifest>
@@ -19,10 +27,15 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(PublishSingleFile)' == 'true'"> <PropertyGroup Condition="'$(PublishSingleFile)' == 'true'">
<!-- Framework-dependent: the WPF runtime pack isn't distributed for cross-compile
on Linux CI, which made self-contained bundles crash on startup with AV in the
apphost. Target machines already have the .NET 8 Desktop Runtime. -->
<SelfContained>false</SelfContained>
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>
<PublishTrimmed>false</PublishTrimmed> <PublishTrimmed>false</PublishTrimmed>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract> <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract> <IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile> <EnableCompressionInSingleFile>false</EnableCompressionInSingleFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

View File

@@ -1,6 +1,8 @@
using System.Diagnostics;
using System.IO; using System.IO;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Installer.Steps; using ClaudeDo.Installer.Steps;
using Microsoft.Win32;
namespace ClaudeDo.Installer.Core; namespace ClaudeDo.Installer.Core;
@@ -36,6 +38,17 @@ public sealed class UninstallRunner
progress.Report("Unregistering service..."); progress.Report("Unregistering service...");
await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct); await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct);
// 3b) Remove Apps & Features registry entry (best-effort).
progress.Report("Removing Add/Remove Programs entry...");
try
{
Registry.LocalMachine.DeleteSubKeyTree(WriteUninstallRegistryStep.UninstallKeyPath, throwOnMissingSubKey: false);
}
catch (Exception ex)
{
progress.Report($"Warning: could not delete uninstall registry key: {ex.Message}");
}
// 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest). // 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest).
progress.Report("Removing shortcuts..."); progress.Report("Removing shortcuts...");
TryDeleteFile(Path.Combine( TryDeleteFile(Path.Combine(
@@ -63,6 +76,21 @@ public sealed class UninstallRunner
failures.Add($"app data ({appData}): {err}"); failures.Add($"app data ({appData}): {err}");
} }
// 7) If we were launched from inside the install dir (Apps & Features case),
// our own exe is still locked — schedule a cmd.exe trampoline to finish
// the deletion after this process exits. Best-effort: if this fails the
// user is left with an empty <uninstaller> folder which is harmless.
var runningExe = Environment.ProcessPath;
if (runningExe is not null
&& IsInsideDirectory(runningExe, _context.InstallDirectory)
&& Directory.Exists(_context.InstallDirectory))
{
progress.Report("Scheduling final cleanup after exit...");
TryScheduleTrampolineDelete(_context.InstallDirectory);
// The trampoline will finish the job — clear the residual failure entry for the install dir.
failures.RemoveAll(f => f.StartsWith("install dir"));
}
if (failures.Count > 0) if (failures.Count > 0)
{ {
return StepResult.Fail( return StepResult.Fail(
@@ -74,6 +102,37 @@ public sealed class UninstallRunner
return StepResult.Ok(); return StepResult.Ok();
} }
private static bool IsInsideDirectory(string filePath, string directory)
{
try
{
var full = Path.GetFullPath(filePath);
var dir = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
return full.StartsWith(dir, StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}
private static void TryScheduleTrampolineDelete(string installDir)
{
try
{
var pid = Environment.ProcessId;
// Wait for this process to exit, then recursively remove the install dir.
// /B timeout avoids a visible window; ping as a portable sleep; rmdir /S /Q is silent.
var cmd = $"/C start \"\" /MIN cmd /C \"ping 127.0.0.1 -n 3 >nul & rmdir /S /Q \"\"{installDir}\"\"\"";
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = cmd,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
});
}
catch { /* best effort */ }
}
/// <summary> /// <summary>
/// Guards against catastrophic recursive-delete paths. The install dir must be /// Guards against catastrophic recursive-delete paths. The install dir must be
/// a non-root directory with a non-empty name (e.g. reject "", "C:\\", "/"). /// a non-root directory with a non-empty name (e.g. reject "", "C:\\", "/").

View File

@@ -54,6 +54,7 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
Steps.Add(new StepViewModel("Initialize Database")); Steps.Add(new StepViewModel("Initialize Database"));
Steps.Add(new StepViewModel("Register Windows Service")); Steps.Add(new StepViewModel("Register Windows Service"));
Steps.Add(new StepViewModel("Create Shortcuts")); Steps.Add(new StepViewModel("Create Shortcuts"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
Steps.Add(new StepViewModel("Write Install Manifest")); Steps.Add(new StepViewModel("Write Install Manifest"));
} }
return Task.CompletedTask; return Task.CompletedTask;
@@ -82,7 +83,21 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
step.Status = p.Status; step.Status = p.Status;
if (p.Message is not null) if (p.Message is not null)
step.Messages.Add(p.Message); {
// Messages starting with "\r" overwrite the previous line (live progress).
if (p.Message.StartsWith('\r'))
{
var line = p.Message[1..];
if (step.Messages.Count > 0 && step.Messages[^1].StartsWith(" "))
step.Messages[^1] = line;
else
step.Messages.Add(line);
}
else
{
step.Messages.Add(p.Message);
}
}
if (p.Status is StepStatus.Running && !step.IsExpanded) if (p.Status is StepStatus.Running && !step.IsExpanded)
step.IsExpanded = true; step.IsExpanded = true;

View File

@@ -44,9 +44,18 @@ public sealed class DownloadAndExtractStep : IInstallStep
var zipPath = Path.Combine(scratchDir, zipAsset.Name); var zipPath = Path.Combine(scratchDir, zipAsset.Name);
var checksumPath = Path.Combine(scratchDir, "checksums.txt"); var checksumPath = Path.Combine(scratchDir, "checksums.txt");
progress.Report($"Downloading {zipAsset.Name} ({zipAsset.Size / (1024 * 1024)} MB)..."); var totalMb = zipAsset.Size / (1024 * 1024);
progress.Report($"Downloading {zipAsset.Name} ({totalMb} MB)...");
long lastReportedMb = -1;
await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath, await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
new Progress<long>(b => progress.Report($" {b / (1024 * 1024)} MB downloaded")), new Progress<long>(b =>
{
var mb = b / (1024 * 1024);
if (mb == lastReportedMb) return;
lastReportedMb = mb;
// Leading "\r" tells the UI to overwrite the previous line instead of appending.
progress.Report($"\r {mb} / {totalMb} MB downloaded");
}),
ct); ct);
progress.Report("Downloading checksums..."); progress.Report("Downloading checksums...");

View File

@@ -23,6 +23,24 @@ public sealed class RegisterServiceStep : IInstallStep
progress.Report("Removing existing service registration (if any)..."); progress.Report("Removing existing service registration (if any)...");
await RunSc($"delete {ServiceName}", ctx, progress, ct, ignoreErrors: true); await RunSc($"delete {ServiceName}", ctx, progress, ct, ignoreErrors: true);
// Wait for the service to actually disappear from SCM. `sc delete` returns
// immediately but the service stays "marked for deletion" until every open
// handle (services.msc, Task Manager, a prior sc query process) is closed.
// Poll up to 30s — then fail with actionable guidance if it's still there.
progress.Report("Waiting for prior service registration to clear...");
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (queryExit, _) = await RunSc($"query {ServiceName}", ctx, progress, ct, ignoreErrors: true);
if (queryExit != 0) break; // service no longer registered — good
if (i == 29)
return StepResult.Fail(
$"Service '{ServiceName}' is marked for deletion but hasn't cleared after 30s. " +
"Close any open Services console (services.msc), Task Manager Services tab, or " +
"Event Viewer showing the service, then retry. A reboot will also clear it.");
await Task.Delay(1000, ct);
}
// Create service // Create service
var startType = ctx.AutoStart ? "auto" : "demand"; var startType = ctx.AutoStart ? "auto" : "demand";
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}"; var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
@@ -35,6 +53,10 @@ public sealed class RegisterServiceStep : IInstallStep
progress.Report("Creating service..."); progress.Report("Creating service...");
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct); var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
if (exitCode == 1072)
return StepResult.Fail(
$"Service '{ServiceName}' is still marked for deletion. " +
"Close services.msc / Task Manager / Event Viewer and retry, or reboot.");
if (exitCode != 0) if (exitCode != 0)
return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}"); return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}");

View File

@@ -27,6 +27,12 @@ public sealed class StopServiceStep : IInstallStep
} }
var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct); var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct);
// 1062 = ERROR_SERVICE_NOT_ACTIVE — registered but not running, treat as already stopped.
if (stopExit == 1062)
{
progress.Report("Service was registered but not running.");
return StepResult.Ok();
}
if (stopExit != 0) if (stopExit != 0)
return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}"); return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}");

View File

@@ -0,0 +1,81 @@
using System.IO;
using ClaudeDo.Installer.Core;
using Microsoft.Win32;
namespace ClaudeDo.Installer.Steps;
/// <summary>
/// Registers ClaudeDo under <c>HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo</c>
/// so it shows up in Windows "Apps &amp; Features" / "Programs and Features".
/// Also copies the running installer into the install directory so there is an exe
/// for UninstallString to reference after the temp-extracted single-file bundle is gone.
/// </summary>
public sealed class WriteUninstallRegistryStep : IInstallStep
{
internal const string UninstallKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo";
public string Name => "Register in Add/Remove Programs";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var uninstallDir = Path.Combine(ctx.InstallDirectory, "uninstaller");
Directory.CreateDirectory(uninstallDir);
var targetExe = Path.Combine(uninstallDir, "ClaudeDo.Installer.exe");
// Copy the running installer so Apps & Features has a stable exe to launch —
// the single-file temp extract is gone once this process exits.
var sourceExe = Environment.ProcessPath
?? throw new InvalidOperationException("Cannot resolve running installer path.");
try
{
progress.Report("Copying uninstaller binary...");
File.Copy(sourceExe, targetExe, overwrite: true);
}
catch (Exception ex)
{
return StepResult.Fail($"Failed to copy uninstaller exe: {ex.Message}");
}
progress.Report("Writing Add/Remove Programs entry...");
try
{
using var key = Registry.LocalMachine.CreateSubKey(UninstallKeyPath, writable: true);
if (key is null)
return StepResult.Fail("Could not open uninstall registry key (permission denied?).");
key.SetValue("DisplayName", "ClaudeDo", RegistryValueKind.String);
key.SetValue("DisplayVersion", ctx.InstallerVersion ?? "0.0.0", RegistryValueKind.String);
key.SetValue("Publisher", "Mika Kuns", RegistryValueKind.String);
key.SetValue("InstallLocation", ctx.InstallDirectory, RegistryValueKind.String);
key.SetValue("UninstallString", $"\"{targetExe}\"", RegistryValueKind.String);
key.SetValue("DisplayIcon", targetExe, RegistryValueKind.String);
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
// Best-effort install size (KB) — scan install dir.
try
{
var sizeKb = (int)(DirectorySizeBytes(ctx.InstallDirectory) / 1024);
key.SetValue("EstimatedSize", sizeKb, RegistryValueKind.DWord);
}
catch { /* best-effort only */ }
}
catch (Exception ex)
{
return StepResult.Fail($"Failed to write uninstall registry: {ex.Message}");
}
await Task.CompletedTask;
return StepResult.Ok();
}
private static long DirectorySizeBytes(string path)
{
long total = 0;
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
{
try { total += new FileInfo(file).Length; } catch { /* ignore */ }
}
return total;
}
}

View File

@@ -184,6 +184,34 @@
</Setter> </Setter>
</Style> </Style>
<!-- ComboBox toggle button (dropdown arrow chrome) -->
<ControlTemplate x:Key="ComboBoxToggleButtonTemplate" TargetType="ToggleButton">
<Border x:Name="Bd"
Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}"
BorderThickness="1"
CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="20"/>
</Grid.ColumnDefinitions>
<Path Grid.Column="1"
HorizontalAlignment="Center" VerticalAlignment="Center"
Fill="{StaticResource TextSecondaryBrush}"
Data="M 0 0 L 4 4 L 8 0 Z"/>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<!-- ComboBox --> <!-- ComboBox -->
<Style TargetType="ComboBox"> <Style TargetType="ComboBox">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/> <Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
@@ -191,6 +219,71 @@
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/> <Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/> <Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/> <Setter Property="Padding" Value="8,6"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<ToggleButton x:Name="ToggleButton"
Template="{StaticResource ComboBoxToggleButtonTemplate}"
Focusable="False"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
ClickMode="Press"/>
<ContentPresenter x:Name="ContentSite"
IsHitTestVisible="False"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
Margin="{TemplateBinding Padding}"
VerticalAlignment="Center" HorizontalAlignment="Left"
TextElement.Foreground="{StaticResource TextPrimaryBrush}"/>
<Popup x:Name="Popup"
Placement="Bottom"
IsOpen="{TemplateBinding IsDropDownOpen}"
AllowsTransparency="True" Focusable="False"
PopupAnimation="Slide">
<Border x:Name="DropDownBorder"
Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}"
BorderThickness="1"
CornerRadius="4"
MinWidth="{TemplateBinding ActualWidth}"
MaxHeight="{TemplateBinding MaxDropDownHeight}">
<ScrollViewer SnapsToDevicePixels="True">
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ComboBoxItem — dark dropdown rows -->
<Style TargetType="ComboBoxItem">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionHoverBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style> </Style>
<!-- CheckBox --> <!-- CheckBox -->

View File

@@ -3,9 +3,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:ClaudeDo.Installer.Views" xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
Title="ClaudeDo Settings" Title="ClaudeDo Settings"
Icon="/ClaudeTaskSetup.ico"
Width="720" Height="520" Width="720" Height="520"
MinWidth="620" MinHeight="460" MinWidth="620" MinHeight="460"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
Background="{StaticResource WindowBgBrush}"
Foreground="{StaticResource TextPrimaryBrush}"
FontFamily="Segoe UI"
FontSize="13"
d:DataContext="{d:DesignInstance views:SettingsViewModel}" d:DataContext="{d:DesignInstance views:SettingsViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -3,9 +3,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:ClaudeDo.Installer.Views" xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
Title="ClaudeDo Installer" Title="ClaudeDo Installer"
Icon="/ClaudeTaskSetup.ico"
Width="720" Height="520" Width="720" Height="520"
MinWidth="620" MinHeight="460" MinWidth="620" MinHeight="460"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
Background="{StaticResource WindowBgBrush}"
Foreground="{StaticResource TextPrimaryBrush}"
FontFamily="Segoe UI"
FontSize="13"
d:DataContext="{d:DesignInstance views:WizardViewModel}" d:DataContext="{d:DesignInstance views:WizardViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -55,6 +55,10 @@ public partial class TaskDetailViewModel : ViewModelBase
private string? _taskId; private string? _taskId;
private string? _listId; private string? _listId;
private bool _isLoading; private bool _isLoading;
// Cancels an in-flight LoadAsync when a new TaskUpdated event arrives
// before the previous load finished — prevents torn state on _taskId,
// Subtasks, Tags, etc.
private CancellationTokenSource? _loadCts;
public event Action<string>? TaskChanged; public event Action<string>? TaskChanged;
@@ -79,78 +83,98 @@ public partial class TaskDetailViewModel : ViewModelBase
public async Task LoadAsync(string taskId) public async Task LoadAsync(string taskId)
{ {
// Cancel any in-flight load so rapid TaskUpdated events don't race
// on _taskId / Subtasks / Tags. The newest caller wins.
var oldCts = _loadCts;
var cts = new CancellationTokenSource();
_loadCts = cts;
oldCts?.Cancel();
oldCts?.Dispose();
var ct = cts.Token;
_taskId = taskId; _taskId = taskId;
LiveText = ""; LiveText = "";
_formatter = new StreamLineFormatter(); _formatter = new StreamLineFormatter();
var task = await _taskRepo.GetByIdAsync(taskId);
if (task is null) return;
if (AvailableAgents.Count == 0)
{
var agents = await _worker.GetAgentsAsync();
AvailableAgents.AddRange(agents);
OnPropertyChanged(nameof(AvailableAgents));
}
_isLoading = true;
try try
{ {
_listId = task.ListId; var task = await _taskRepo.GetByIdAsync(taskId, ct);
Title = task.Title; if (task is null) return;
Description = task.Description; ct.ThrowIfCancellationRequested();
Result = task.Result;
LogPath = task.LogPath; if (AvailableAgents.Count == 0)
if (task.LogPath is not null
&& task.Status is Data.Models.TaskStatus.Done or Data.Models.TaskStatus.Failed
&& File.Exists(task.LogPath))
{ {
_formatter = new StreamLineFormatter(); var agents = await _worker.GetAgentsAsync();
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath)); ct.ThrowIfCancellationRequested();
AvailableAgents.AddRange(agents);
OnPropertyChanged(nameof(AvailableAgents));
} }
StatusText = task.Status.ToString().ToLowerInvariant();
StatusChoice = task.Status.ToString(); _isLoading = true;
CommitType = task.CommitType; try
ModelChoice = task.Model is not null
? ListEditorViewModel.ModelIdToDisplay(task.Model)
: "(list default)";
SystemPromptOverride = task.SystemPrompt;
if (task.AgentPath is not null)
{ {
var match = AvailableAgents.FirstOrDefault(a => a.Path == task.AgentPath); _listId = task.ListId;
if (match is null) Title = task.Title;
Description = task.Description;
Result = task.Result;
LogPath = task.LogPath;
if (task.LogPath is not null
&& task.Status is Data.Models.TaskStatus.Done or Data.Models.TaskStatus.Failed
&& File.Exists(task.LogPath))
{ {
match = new AgentInfo(Path.GetFileNameWithoutExtension(task.AgentPath), "(external)", task.AgentPath); _formatter = new StreamLineFormatter();
AvailableAgents.Add(match); LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath), ct);
OnPropertyChanged(nameof(AvailableAgents)); }
StatusText = task.Status.ToString().ToLowerInvariant();
StatusChoice = task.Status.ToString();
CommitType = task.CommitType;
ModelChoice = task.Model is not null
? ListEditorViewModel.ModelIdToDisplay(task.Model)
: "(list default)";
SystemPromptOverride = task.SystemPrompt;
if (task.AgentPath is not null)
{
var match = AvailableAgents.FirstOrDefault(a => a.Path == task.AgentPath);
if (match is null)
{
match = new AgentInfo(Path.GetFileNameWithoutExtension(task.AgentPath), "(external)", task.AgentPath);
AvailableAgents.Add(match);
OnPropertyChanged(nameof(AvailableAgents));
}
SelectedAgent = match;
}
else
{
SelectedAgent = null;
}
Tags.Clear();
var tags = await _taskRepo.GetTagsAsync(taskId, ct);
foreach (var tag in tags)
Tags.Add(tag);
// Tear down old subtask subscriptions before replacing them.
foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged;
Subtasks.Clear();
var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId, ct);
foreach (var s in subtasks)
{
var vm = SubtaskItemViewModel.From(s);
vm.PropertyChanged += OnSubtaskPropertyChanged;
Subtasks.Add(vm);
} }
SelectedAgent = match;
} }
else finally
{ {
SelectedAgent = null; _isLoading = false;
} }
Tags.Clear(); await LoadWorktreeAsync(taskId);
var tags = await _taskRepo.GetTagsAsync(taskId);
foreach (var tag in tags)
Tags.Add(tag);
Subtasks.Clear();
var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId);
foreach (var s in subtasks)
{
var vm = SubtaskItemViewModel.From(s);
vm.PropertyChanged += OnSubtaskPropertyChanged;
Subtasks.Add(vm);
}
} }
finally catch (OperationCanceledException)
{ {
_isLoading = false; // Superseded by a newer LoadAsync — nothing to do.
} }
await LoadWorktreeAsync(taskId);
} }
public async Task SaveAsync() public async Task SaveAsync()
@@ -236,15 +260,23 @@ public partial class TaskDetailViewModel : ViewModelBase
{ {
if (_isLoading || sender is not SubtaskItemViewModel vm || string.IsNullOrEmpty(vm.Id)) return; if (_isLoading || sender is not SubtaskItemViewModel vm || string.IsNullOrEmpty(vm.Id)) return;
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return; if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
await _subtaskRepo.UpdateAsync(new SubtaskEntity try
{ {
Id = vm.Id, await _subtaskRepo.UpdateAsync(new SubtaskEntity
TaskId = _taskId ?? "", {
Title = vm.Title, Id = vm.Id,
Completed = vm.Completed, TaskId = _taskId ?? "",
OrderNum = Subtasks.IndexOf(vm), Title = vm.Title,
CreatedAt = DateTime.UtcNow, Completed = vm.Completed,
}); OrderNum = Subtasks.IndexOf(vm),
CreatedAt = DateTime.UtcNow,
});
}
catch (Exception ex)
{
// async void must never throw — surface via Debug.
Debug.WriteLine($"[TaskDetailViewModel] Subtask update failed for {vm.Id}: {ex}");
}
} }
public void SetAgentFromPath(string path) public void SetAgentFromPath(string path)
@@ -261,6 +293,11 @@ public partial class TaskDetailViewModel : ViewModelBase
public void Clear() public void Clear()
{ {
// Cancel any load in flight so it doesn't resurrect state after Clear.
_loadCts?.Cancel();
_loadCts?.Dispose();
_loadCts = null;
_taskId = null; _taskId = null;
_listId = null; _listId = null;
Title = ""; Title = "";
@@ -408,12 +445,28 @@ public partial class TaskDetailViewModel : ViewModelBase
private async void OnWorktreeUpdated(string taskId) private async void OnWorktreeUpdated(string taskId)
{ {
if (taskId != _taskId) return; if (taskId != _taskId) return;
await LoadWorktreeAsync(taskId); try
{
await LoadWorktreeAsync(taskId);
}
catch (Exception ex)
{
// async void must never throw.
Debug.WriteLine($"[TaskDetailViewModel] OnWorktreeUpdated failed for {taskId}: {ex}");
}
} }
private async void OnTaskUpdated(string taskId) private async void OnTaskUpdated(string taskId)
{ {
if (taskId != _taskId) return; if (taskId != _taskId) return;
await LoadAsync(taskId); try
{
await LoadAsync(taskId);
}
catch (Exception ex)
{
// async void must never throw.
Debug.WriteLine($"[TaskDetailViewModel] OnTaskUpdated failed for {taskId}: {ex}");
}
} }
} }

View File

@@ -8,6 +8,7 @@
x:Class="ClaudeDo.Ui.Views.MainWindow" x:Class="ClaudeDo.Ui.Views.MainWindow"
x:DataType="vm:MainWindowViewModel" x:DataType="vm:MainWindowViewModel"
Title="ClaudeDo" Title="ClaudeDo"
Icon="avares://ClaudeDo.App/Assets/ClaudeTask.ico"
MinWidth="800" MinHeight="500" MinWidth="800" MinHeight="500"
KeyDown="OnGlobalKeyDown"> KeyDown="OnGlobalKeyDown">

View File

@@ -6,6 +6,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>

View File

@@ -10,6 +10,10 @@ var cfg = WorkerConfig.Load();
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// When launched by the Windows SCM, speak the Service Control Protocol so SCM
// doesn't think we crashed (~30s timeout). No-op when running interactively.
builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker");
// Initialize DB schema before the host starts accepting connections. // Initialize DB schema before the host starts accepting connections.
var dbFactory = new SqliteConnectionFactory(cfg.DbPath); var dbFactory = new SqliteConnectionFactory(cfg.DbPath);
SchemaInitializer.Apply(dbFactory); SchemaInitializer.Apply(dbFactory);

View File

@@ -45,6 +45,9 @@ public sealed class ClaudeProcess : IClaudeProcess
var analyzer = new StreamAnalyzer(); var analyzer = new StreamAnalyzer();
var lastStderr = new StringBuilder(); var lastStderr = new StringBuilder();
// On cancellation: kill the tree. Killing closes the redirected pipes,
// which unblocks the ReadLineAsync loops below (which run without ct
// so they reliably drain instead of hanging on cancellation).
await using var ctr = ct.Register(() => await using var ctr = ct.Register(() =>
{ {
try { process.Kill(entireProcessTree: true); } try { process.Kill(entireProcessTree: true); }
@@ -53,26 +56,30 @@ public sealed class ClaudeProcess : IClaudeProcess
var stdoutTask = Task.Run(async () => var stdoutTask = Task.Run(async () =>
{ {
while (await process.StandardOutput.ReadLineAsync(ct) is { } line) while (await process.StandardOutput.ReadLineAsync() is { } line)
{ {
if (string.IsNullOrEmpty(line)) continue; if (string.IsNullOrEmpty(line)) continue;
await onStdoutLine(line); await onStdoutLine(line);
analyzer.ProcessLine(line); analyzer.ProcessLine(line);
} }
}, ct); });
var stderrTask = Task.Run(async () => var stderrTask = Task.Run(async () =>
{ {
while (await process.StandardError.ReadLineAsync(ct) is { } line) while (await process.StandardError.ReadLineAsync() is { } line)
{ {
if (string.IsNullOrEmpty(line)) continue; if (string.IsNullOrEmpty(line)) continue;
lastStderr.AppendLine(line); lastStderr.AppendLine(line);
await onStdoutLine($"[stderr] {line}"); await onStdoutLine($"[stderr] {line}");
} }
}, ct); });
await Task.WhenAll(stdoutTask, stderrTask); await Task.WhenAll(stdoutTask, stderrTask);
await process.WaitForExitAsync(ct); await process.WaitForExitAsync(CancellationToken.None);
// If we were asked to cancel, surface that to the caller now that
// the process is fully reaped.
ct.ThrowIfCancellationRequested();
var exitCode = process.ExitCode; var exitCode = process.ExitCode;
var streamResult = analyzer.GetResult(); var streamResult = analyzer.GetResult();

View File

@@ -232,33 +232,56 @@ public sealed class TaskRunner
await using var logWriter = new LogWriter(logPath); await using var logWriter = new LogWriter(logPath);
var result = await _claude.RunAsync( try
arguments, {
prompt, var result = await _claude.RunAsync(
runDir, arguments,
async line => prompt,
runDir,
async line =>
{
await logWriter.WriteLineAsync(line, ct);
await _broadcaster.TaskMessage(taskId, line);
},
ct);
// Update the run record with results. Use CancellationToken.None:
// this is a terminal write that must always complete, even if the
// caller's token is already cancelled.
run.SessionId = result.SessionId;
run.ResultMarkdown = result.ResultMarkdown;
run.StructuredOutputJson = result.StructuredOutputJson;
run.ErrorMarkdown = result.ErrorMarkdown;
run.ExitCode = result.ExitCode;
run.TurnCount = result.TurnCount;
run.TokensIn = result.TokensIn;
run.TokensOut = result.TokensOut;
run.FinishedAt = DateTime.UtcNow;
await _runRepo.UpdateAsync(run, CancellationToken.None);
// Update denormalized fields on the task.
await _taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
return result;
}
catch (OperationCanceledException)
{
// Ensure the run row is completed so ContinueAsync / inspection
// isn't left staring at a null session_id / finished_at.
run.ErrorMarkdown = "Cancelled.";
run.ExitCode = -1;
run.FinishedAt = DateTime.UtcNow;
try
{ {
await logWriter.WriteLineAsync(line, ct); await _runRepo.UpdateAsync(run, CancellationToken.None);
await _broadcaster.TaskMessage(taskId, line); await _taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
}, }
ct); catch (Exception updateEx)
{
// Update the run record with results. _logger.LogError(updateEx, "Failed to finalize cancelled run {RunId} for task {TaskId}", runId, taskId);
run.SessionId = result.SessionId; }
run.ResultMarkdown = result.ResultMarkdown; throw;
run.StructuredOutputJson = result.StructuredOutputJson; }
run.ErrorMarkdown = result.ErrorMarkdown;
run.ExitCode = result.ExitCode;
run.TurnCount = result.TurnCount;
run.TokensIn = result.TokensIn;
run.TokensOut = result.TokensOut;
run.FinishedAt = DateTime.UtcNow;
await _runRepo.UpdateAsync(run, ct);
// Update denormalized fields on the task.
await _taskRepo.SetLogPathAsync(taskId, logPath, ct);
return result;
} }
private async Task HandleSuccess(TaskEntity task, ListEntity list, string slot, WorktreeContext? wtCtx, RunResult result, CancellationToken ct) private async Task HandleSuccess(TaskEntity task, ListEntity list, string slot, WorktreeContext? wtCtx, RunResult result, CancellationToken ct)
@@ -270,8 +293,11 @@ public sealed class TaskRunner
await _broadcaster.WorktreeUpdated(task.Id); await _broadcaster.WorktreeUpdated(task.Id);
} }
// Terminal DB write uses CancellationToken.None so the task status
// is never left as 'running' because of a cancel that arrived
// after the Claude run already succeeded.
var finishedAt = DateTime.UtcNow; var finishedAt = DateTime.UtcNow;
await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, ct); await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt); await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})", _logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
task.Id, result.TurnCount, result.TokensIn, result.TokensOut); task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
@@ -279,8 +305,10 @@ public sealed class TaskRunner
private async Task HandleFailure(string taskId, string slot, RunResult result) private async Task HandleFailure(string taskId, string slot, RunResult result)
{ {
// Intentionally does not accept a CancellationToken: this is the
// terminal write for a failed task and must always be persisted.
var finishedAt = DateTime.UtcNow; var finishedAt = DateTime.UtcNow;
await _taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown); await _taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None);
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt); await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown); _logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
} }
@@ -290,7 +318,8 @@ public sealed class TaskRunner
try try
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
await _taskRepo.MarkFailedAsync(taskId, now, error); // Terminal write — never cancel.
await _taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None);
await _broadcaster.TaskFinished(slot, taskId, "failed", now); await _broadcaster.TaskFinished(slot, taskId, "failed", now);
await _broadcaster.TaskUpdated(taskId); await _broadcaster.TaskUpdated(taskId);
} }

View File

@@ -31,8 +31,10 @@ public sealed class WorktreeManager
throw new InvalidOperationException($"working_dir is not a git repository: {workingDir}"); throw new InvalidOperationException($"working_dir is not a git repository: {workingDir}");
var baseCommit = await _git.RevParseHeadAsync(workingDir, ct); var baseCommit = await _git.RevParseHeadAsync(workingDir, ct);
var shortId = task.Id.Length >= 8 ? task.Id[..8] : task.Id; // Use the full task id (dashes stripped) in the branch name so
var branchName = $"claudedo/{shortId}"; // two GUIDs sharing an 8-char prefix cannot collide on the same branch.
var idForBranch = task.Id.Replace("-", "");
var branchName = $"claudedo/{idForBranch}";
var slug = CommitMessageBuilder.ToSlug(list.Name); var slug = CommitMessageBuilder.ToSlug(list.Name);
var worktreePath = _cfg.WorktreeRootStrategy.Equals("central", StringComparison.OrdinalIgnoreCase) var worktreePath = _cfg.WorktreeRootStrategy.Equals("central", StringComparison.OrdinalIgnoreCase)

View File

@@ -71,6 +71,7 @@ public sealed class QueueService : BackgroundService
_ = RunInSlotAsync(task, "override", cts.Token).ContinueWith(_ => _ = RunInSlotAsync(task, "override", cts.Token).ContinueWith(_ =>
{ {
lock (_lock) { _overrideSlot = null; } lock (_lock) { _overrideSlot = null; }
cts.Dispose();
}, TaskScheduler.Default); }, TaskScheduler.Default);
} }
} }
@@ -94,6 +95,7 @@ public sealed class QueueService : BackgroundService
_ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(_ => _ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(_ =>
{ {
lock (_lock) { _overrideSlot = null; } lock (_lock) { _overrideSlot = null; }
cts.Dispose();
}, TaskScheduler.Default); }, TaskScheduler.Default);
} }
@@ -155,6 +157,7 @@ public sealed class QueueService : BackgroundService
_ = RunInSlotAsync(task, "queue", cts.Token).ContinueWith(_ => _ = RunInSlotAsync(task, "queue", cts.Token).ContinueWith(_ =>
{ {
lock (_lock) { _queueSlot = null; } lock (_lock) { _queueSlot = null; }
cts.Dispose();
WakeQueue(); // Check for next task immediately. WakeQueue(); // Check for next task immediately.
}, TaskScheduler.Default); }, TaskScheduler.Default);
} }

View File

@@ -7,6 +7,8 @@
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
<!-- Allow Linux Gitea runners to build this Windows-targeted project; no-op on Windows. -->
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -63,7 +63,7 @@ public class WorktreeManagerTests : IDisposable
Assert.NotNull(ctx); Assert.NotNull(ctx);
Assert.True(Directory.Exists(ctx.WorktreePath)); Assert.True(Directory.Exists(ctx.WorktreePath));
Assert.Equal($"claudedo/{task.Id[..8]}", ctx.BranchName); Assert.Equal($"claudedo/{task.Id.Replace("-", "")}", ctx.BranchName);
Assert.Equal(repo.BaseCommit, ctx.BaseCommit); Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
var row = await wtRepo.GetByTaskIdAsync(task.Id); var row = await wtRepo.GetByTaskIdAsync(task.Id);