feat(worker): Ticket-Status-Rueckmeldung ueber TaskStateService
This commit is contained in:
@@ -125,7 +125,8 @@ builder.Services.AddSingleton<ITaskStateService>(sp => new TaskStateService(
|
||||
sp.GetRequiredService<RunCancellationRegistry>(),
|
||||
sp.GetRequiredService<Func<IActiveMergeState>>(),
|
||||
sp.GetRequiredService<BaseDirtyChecker>(),
|
||||
sp.GetRequiredService<ILogger<TaskStateService>>()));
|
||||
sp.GetRequiredService<ILogger<TaskStateService>>(),
|
||||
sp.GetRequiredService<TicketStatusSync>()));
|
||||
|
||||
// Agent file management.
|
||||
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
||||
@@ -219,6 +220,7 @@ builder.Services.AddSingleton(new TicketPatStore(DpapiTokenStore.InAppData("tick
|
||||
builder.Services.AddSingleton<TicketSystemConfig>();
|
||||
builder.Services.AddHttpClient("tickets");
|
||||
builder.Services.AddSingleton<TicketClientFactory>();
|
||||
builder.Services.AddSingleton<TicketStatusSync>();
|
||||
|
||||
if (cfg.OnlineInbox.Enabled)
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ using ClaudeDo.Worker.Git;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.Tickets;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
@@ -20,6 +21,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
private readonly Func<IActiveMergeState> _mergeState;
|
||||
private readonly BaseDirtyChecker _baseDirtyChecker;
|
||||
private readonly ILogger<TaskStateService> _logger;
|
||||
private readonly TicketStatusSync _ticketSync;
|
||||
|
||||
public TaskStateService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
@@ -29,7 +31,8 @@ public sealed class TaskStateService : ITaskStateService
|
||||
RunCancellationRegistry runCancels,
|
||||
Func<IActiveMergeState> mergeState,
|
||||
BaseDirtyChecker baseDirtyChecker,
|
||||
ILogger<TaskStateService> logger)
|
||||
ILogger<TaskStateService> logger,
|
||||
TicketStatusSync ticketSync)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_broadcaster = broadcaster;
|
||||
@@ -39,6 +42,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
_mergeState = mergeState;
|
||||
_baseDirtyChecker = baseDirtyChecker;
|
||||
_logger = logger;
|
||||
_ticketSync = ticketSync;
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct)
|
||||
@@ -59,7 +63,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
return new TransitionResult(false, "Task not found or already running.");
|
||||
|
||||
_waker.Wake();
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
|
||||
// Heads-up only: a worktree forks from the commit tip (WorktreeManager.ResolveBaseCommitAsync),
|
||||
// not the working tree, so uncommitted changes sitting in the list's repo right now are
|
||||
@@ -92,7 +96,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task already running or not found.");
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -112,7 +116,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
}
|
||||
|
||||
await OnChildTerminalAsync(taskId, TaskStatus.Done);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -129,7 +133,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task not running; cannot submit for review.");
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -149,7 +153,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task is not Idle or Failed; cannot submit for review.");
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -166,7 +170,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task not running; cannot submit for children.");
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -186,7 +190,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
}
|
||||
|
||||
await OnChildTerminalAsync(taskId, TaskStatus.Done);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -208,7 +212,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
return new TransitionResult(false, "Task is not waiting for review; cannot reject.");
|
||||
|
||||
_waker.Wake();
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -224,7 +228,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task is not waiting for review; cannot park.");
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -265,7 +269,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
}
|
||||
|
||||
await OnChildTerminalAsync(taskId, TaskStatus.Failed);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -329,9 +333,9 @@ public sealed class TaskStateService : ITaskStateService
|
||||
_runCancels.TryCancel(childId);
|
||||
|
||||
await OnChildTerminalAsync(taskId, TaskStatus.Cancelled);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
foreach (var childId in cancelledChildIds)
|
||||
await _broadcaster.TaskUpdated(childId);
|
||||
await NotifyAsync(childId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -349,7 +353,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task is running; cannot reset.");
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -375,7 +379,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
return new TransitionResult(false, "Task not found.");
|
||||
|
||||
if (status == TaskStatus.Queued) _waker.Wake();
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -396,7 +400,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task is running or no longer exists; cannot mark done.");
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -410,7 +414,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task is not Done; cannot unmark.");
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -426,7 +430,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task is not queued; cannot remove from queue.");
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -443,7 +447,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task not in plannable state.");
|
||||
|
||||
await _broadcaster.TaskUpdated(parentId);
|
||||
await NotifyAsync(parentId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -464,7 +468,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "No active planning session.");
|
||||
|
||||
await _broadcaster.TaskUpdated(parentId);
|
||||
await NotifyAsync(parentId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -478,7 +482,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "Task not found.");
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -493,7 +497,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
return new TransitionResult(false, "Task not found.");
|
||||
|
||||
_waker.Wake();
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -537,7 +541,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
|
||||
// Clearing a dependency may free up a Queued task the picker was skipping.
|
||||
if (dependsOnTaskId is null) _waker.Wake();
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
@@ -672,6 +676,17 @@ public sealed class TaskStateService : ITaskStateService
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.WaitingForReview)
|
||||
.SetProperty(t => t.Result, newResult), CancellationToken.None);
|
||||
await _broadcaster.TaskUpdated(parentId);
|
||||
await NotifyAsync(parentId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der einzige Broadcast-Pfad dieser Klasse. Zusätzlich zum UI-Update meldet er den neuen
|
||||
/// Status ans verknüpfte Ticket. Nie direkt _broadcaster.TaskUpdated aufrufen — sonst
|
||||
/// fällt genau dieser Übergang aus dem Ticket-Sync heraus.
|
||||
/// </summary>
|
||||
private async Task NotifyAsync(string taskId)
|
||||
{
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
await _ticketSync.SyncAsync(taskId, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tickets;
|
||||
|
||||
/// <summary>
|
||||
/// Reine Abbildung Task-Status → Ticket-Status. Ticket-Status im Bandel-System:
|
||||
/// 0 Keine, 1 Offen, 2 InBearbeitung, 3 Fertig, 4 Archiviert.
|
||||
/// </summary>
|
||||
public static class TicketStatusMap
|
||||
{
|
||||
public const int InBearbeitung = 2;
|
||||
public const int Fertig = 3;
|
||||
|
||||
private const string BandelPrefix = "bandel:";
|
||||
|
||||
/// <summary>
|
||||
/// Zielstatus im Ticketsystem, oder null wenn dieser Task-Status nichts melden soll.
|
||||
/// "Offen" (1) wird nie geschrieben: das ist der Eingangszustand. Failed/Cancelled lassen
|
||||
/// das Ticket bewusst auf InBearbeitung stehen — die Arbeit ist angefangen, nicht zurückgegeben.
|
||||
/// </summary>
|
||||
public static int? ToTicketStatus(TaskStatus status) => status switch
|
||||
{
|
||||
TaskStatus.Running or TaskStatus.WaitingForReview => InBearbeitung,
|
||||
TaskStatus.Done => Fertig,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Ticket-Id aus einem "bandel:<id>"-Ref, oder null bei jedem anderen Format.</summary>
|
||||
public static int? ParseBandelTicketId(string? ticketRef)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ticketRef)) return null;
|
||||
if (!ticketRef.StartsWith(BandelPrefix, StringComparison.OrdinalIgnoreCase)) return null;
|
||||
var raw = ticketRef[BandelPrefix.Length..];
|
||||
return int.TryParse(raw, out var id) && id > 0 ? id : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.Tickets;
|
||||
|
||||
/// <summary>
|
||||
/// Meldet den Task-Status ans verknüpfte Ticket zurück. Hängt an genau einem Punkt im System:
|
||||
/// TaskStateService.NotifyAsync, dem einzigen Ort, an dem Statuswechsel zusammenlaufen.
|
||||
///
|
||||
/// Zwei Invarianten:
|
||||
/// * Wirft nie. Ein Ticketsystem-Ausfall darf keinen Statuswechsel und keine Queue anhalten.
|
||||
/// * Kostet nichts, wenn nichts eingerichtet ist — IsConfigured bricht vor jeder DB-Abfrage ab.
|
||||
/// </summary>
|
||||
public sealed class TicketStatusSync
|
||||
{
|
||||
private readonly TicketSystemConfig _config;
|
||||
private readonly TicketClientFactory _clients;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly ILogger<TicketStatusSync> _logger;
|
||||
|
||||
// taskId → zuletzt geschriebener Ticket-Status. Verhindert den redundanten PATCH bei
|
||||
// Running → WaitingForReview (beide sind "InBearbeitung"). Bewusst nur im Prozessspeicher:
|
||||
// nach einem Neustart ist ein überzähliger PATCH harmlos.
|
||||
private readonly ConcurrentDictionary<string, int> _lastWritten = new();
|
||||
|
||||
public TicketStatusSync(
|
||||
TicketSystemConfig config,
|
||||
TicketClientFactory clients,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
ILogger<TicketStatusSync> logger)
|
||||
{
|
||||
_config = config;
|
||||
_clients = clients;
|
||||
_dbFactory = dbFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SyncAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
if (!_config.IsConfigured) return;
|
||||
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var row = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Id == taskId)
|
||||
.Select(t => new { t.Status, t.TicketRef })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (row?.TicketRef is null) return;
|
||||
|
||||
var ticketId = TicketStatusMap.ParseBandelTicketId(row.TicketRef);
|
||||
if (ticketId is null) return;
|
||||
|
||||
var target = TicketStatusMap.ToTicketStatus(row.Status);
|
||||
if (target is null) return;
|
||||
|
||||
if (_lastWritten.TryGetValue(taskId, out var previous) && previous == target.Value) return;
|
||||
|
||||
var client = _clients.Create();
|
||||
if (client is null) return;
|
||||
|
||||
await client.SetStatusAsync(ticketId.Value, target.Value, ct);
|
||||
_lastWritten[taskId] = target.Value;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Ticket {ticket_id} auf Status {ticket_status} gesetzt (Task {task_id})",
|
||||
ticketId.Value, target.Value, taskId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Serilog Warn landet über BroadcastLogSink im Footer-Log-Strip.
|
||||
_logger.LogWarning(ex,
|
||||
"Ticket-Status für Task {task_id} konnte nicht geschrieben werden: {reason}",
|
||||
taskId, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user