3 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
16 changed files with 262 additions and 123 deletions

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,9 +22,20 @@ sealed class Program
var factory = services.GetRequiredService<SqliteConnectionFactory>(); var factory = services.GetRequiredService<SqliteConnectionFactory>();
SchemaInitializer.Apply(factory); SchemaInitializer.Apply(factory);
try
{
BuildAvaloniaApp() BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args); .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()
=> AppBuilder.Configure<App>() => AppBuilder.Configure<App>()

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,12 +174,18 @@ 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 = (
SELECT t.id
FROM tasks t FROM tasks t
WHERE t.status = 'queued' WHERE t.status = 'queued'
AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now) AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now)
@@ -194,6 +200,10 @@ public sealed class TaskRepository
) )
ORDER BY t.created_at ASC ORDER BY t.created_at ASC
LIMIT 1 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

@@ -8,8 +8,14 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<!-- Allow Linux Gitea runners to publish this WPF project for win-x64; no-op on Windows. --> <!-- Allow Linux Gitea runners to publish this WPF project for win-x64; no-op on Windows. -->
<EnableWindowsTargeting>true</EnableWindowsTargeting> <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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

View File

@@ -3,6 +3,7 @@
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"

View File

@@ -3,6 +3,7 @@
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"

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,16 +83,29 @@ 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); try
{
var task = await _taskRepo.GetByIdAsync(taskId, ct);
if (task is null) return; if (task is null) return;
ct.ThrowIfCancellationRequested();
if (AvailableAgents.Count == 0) if (AvailableAgents.Count == 0)
{ {
var agents = await _worker.GetAgentsAsync(); var agents = await _worker.GetAgentsAsync();
ct.ThrowIfCancellationRequested();
AvailableAgents.AddRange(agents); AvailableAgents.AddRange(agents);
OnPropertyChanged(nameof(AvailableAgents)); OnPropertyChanged(nameof(AvailableAgents));
} }
@@ -106,7 +123,7 @@ public partial class TaskDetailViewModel : ViewModelBase
&& File.Exists(task.LogPath)) && File.Exists(task.LogPath))
{ {
_formatter = new StreamLineFormatter(); _formatter = new StreamLineFormatter();
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath)); LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath), ct);
} }
StatusText = task.Status.ToString().ToLowerInvariant(); StatusText = task.Status.ToString().ToLowerInvariant();
StatusChoice = task.Status.ToString(); StatusChoice = task.Status.ToString();
@@ -132,12 +149,14 @@ public partial class TaskDetailViewModel : ViewModelBase
} }
Tags.Clear(); Tags.Clear();
var tags = await _taskRepo.GetTagsAsync(taskId); var tags = await _taskRepo.GetTagsAsync(taskId, ct);
foreach (var tag in tags) foreach (var tag in tags)
Tags.Add(tag); Tags.Add(tag);
// Tear down old subtask subscriptions before replacing them.
foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged;
Subtasks.Clear(); Subtasks.Clear();
var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId); var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId, ct);
foreach (var s in subtasks) foreach (var s in subtasks)
{ {
var vm = SubtaskItemViewModel.From(s); var vm = SubtaskItemViewModel.From(s);
@@ -152,6 +171,11 @@ public partial class TaskDetailViewModel : ViewModelBase
await LoadWorktreeAsync(taskId); await LoadWorktreeAsync(taskId);
} }
catch (OperationCanceledException)
{
// Superseded by a newer LoadAsync — nothing to do.
}
}
public async Task SaveAsync() public async Task SaveAsync()
{ {
@@ -236,6 +260,8 @@ 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;
try
{
await _subtaskRepo.UpdateAsync(new SubtaskEntity await _subtaskRepo.UpdateAsync(new SubtaskEntity
{ {
Id = vm.Id, Id = vm.Id,
@@ -246,6 +272,12 @@ public partial class TaskDetailViewModel : ViewModelBase
CreatedAt = DateTime.UtcNow, 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;
try
{
await LoadWorktreeAsync(taskId); 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;
try
{
await LoadAsync(taskId); 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

@@ -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,6 +232,8 @@ public sealed class TaskRunner
await using var logWriter = new LogWriter(logPath); await using var logWriter = new LogWriter(logPath);
try
{
var result = await _claude.RunAsync( var result = await _claude.RunAsync(
arguments, arguments,
prompt, prompt,
@@ -243,7 +245,9 @@ public sealed class TaskRunner
}, },
ct); ct);
// Update the run record with results. // 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.SessionId = result.SessionId;
run.ResultMarkdown = result.ResultMarkdown; run.ResultMarkdown = result.ResultMarkdown;
run.StructuredOutputJson = result.StructuredOutputJson; run.StructuredOutputJson = result.StructuredOutputJson;
@@ -253,13 +257,32 @@ public sealed class TaskRunner
run.TokensIn = result.TokensIn; run.TokensIn = result.TokensIn;
run.TokensOut = result.TokensOut; run.TokensOut = result.TokensOut;
run.FinishedAt = DateTime.UtcNow; run.FinishedAt = DateTime.UtcNow;
await _runRepo.UpdateAsync(run, ct); await _runRepo.UpdateAsync(run, CancellationToken.None);
// Update denormalized fields on the task. // Update denormalized fields on the task.
await _taskRepo.SetLogPathAsync(taskId, logPath, ct); await _taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
return result; 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 _runRepo.UpdateAsync(run, CancellationToken.None);
await _taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
}
catch (Exception updateEx)
{
_logger.LogError(updateEx, "Failed to finalize cancelled run {RunId} for task {TaskId}", runId, taskId);
}
throw;
}
}
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

@@ -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);