Compare commits
3 Commits
fc9029de97
...
3b1f148122
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b1f148122 | ||
|
|
2b3fe02d8c | ||
|
|
d3b85f2234 |
BIN
src/ClaudeDo.App/Assets/ClaudeTask.ico
Normal file
BIN
src/ClaudeDo.App/Assets/ClaudeTask.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 317 B |
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
BIN
src/ClaudeDo.Installer/ClaudeTaskSetup.ico
Normal file
BIN
src/ClaudeDo.Installer/ClaudeTaskSetup.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 374 B |
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user