fix(worker): Stuck-Running-Fenster in Continue- und Stop-Pfad schließen
TaskRunner.ContinueAsync: Claim, SeedAsync und SetupMcpConfigAsync liefen vor dem try/catch (anders als RunAsync) - warf einer davon nach dem Running-Claim, propagierte die Exception ungefangen bis zu OverrideSlotService.RunContinueInSlotAsync, das nur loggt. Task blieb Running. Fix: derselbe Aufbau wie RunAsync (Claim+Seed+MCP-Setup im try, MarkFailed im catch, mcpToken/mcpConfigPath vor dem try auf null). SetupMcpConfigAsync bekommt zusätzlich einen onTokenRegistered-Callback, damit die äußere mcpToken-Variable den Token sofort nach dem Register sieht - sonst hätte ein Fehler zwischen Register und Rückgabe (z.B. File.WriteAllTextAsync) den Token in der TaskRunTokenRegistry geleakt (betrifft denselben Aufruf in RunAsync mit, daher dort ebenfalls verdrahtet - RunAsync-Struktur selbst unverändert). QueueService.RunInSlotAsync: der Stop-Button (CancelTask) cancelt die Slot-CTS direkt ohne TaskStateService-Schreibzugriff. Traf das die Pre-Dispatch-DB-Reads, loggte der OCE-Catch nur und die vom Picker bereits auf Running geclaimte Task blieb dort für immer hängen. Fix: Status im Catch neu lesen und nur wenn er noch Running ist über TaskStateService.CancelAsync auf Cancelled setzen - ein Ursprung, der bereits selbst einen Terminalstatus geschrieben hat (z.B. CancelReview), wird nicht überschrieben. Kommentar korrigiert. TDD: neue Tests in ContinueAsyncExceptionTests (Seed-/MCP-Setup-Fehler nach Claim -> Failed, kein Token-Leak) und QueueServiceSlotFailureTests (Stop während Pre-Dispatch -> Cancelled statt Running; ein bereits terminal geschriebener Status wird nicht gestompt) vorher rot, jetzt grün. Worker.Tests: 1213/1213 grün, Worker baut in Release.
This commit is contained in:
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Skills;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
@@ -29,14 +30,101 @@ public sealed class ContinueAsyncExceptionTests : IDisposable
|
||||
|
||||
public void Dispose() { _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } }
|
||||
|
||||
private TaskRunner BuildRunner(IClaudeProcess claude, ClaudeDoDbContext ctx)
|
||||
private TaskRunner BuildRunner(
|
||||
IClaudeProcess claude, ClaudeDoDbContext ctx,
|
||||
ISessionSkillSeeder? skillSeeder = null, TaskRunTokenRegistry? tokens = null, WorkerConfig? cfg = null)
|
||||
{
|
||||
var dbFactory = _db.CreateFactory();
|
||||
var broadcaster = new HubBroadcaster(new CapturingHubContext());
|
||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||
var wt = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
|
||||
return new TaskRunner(claude, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg,
|
||||
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
|
||||
var effectiveCfg = cfg ?? _cfg;
|
||||
var wt = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, effectiveCfg, NullLogger<WorktreeManager>.Instance);
|
||||
return new TaskRunner(claude, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), effectiveCfg,
|
||||
NullLogger<TaskRunner>.Instance, state, tokens ?? new TaskRunTokenRegistry(), new AttachmentStore(),
|
||||
skillSeeder ?? new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
|
||||
}
|
||||
|
||||
private async Task<string> SeedContinuableTaskAsync(string sessionId)
|
||||
{
|
||||
string listId, taskId;
|
||||
using var ctx = _db.CreateContext();
|
||||
listId = Guid.NewGuid().ToString();
|
||||
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow });
|
||||
|
||||
taskId = Guid.NewGuid().ToString();
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = taskId,
|
||||
ListId = listId,
|
||||
Title = "Continue me",
|
||||
Status = TaskStatus.WaitingForReview,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
TaskId = taskId,
|
||||
RunNumber = 1,
|
||||
IsRetry = false,
|
||||
Prompt = "original prompt",
|
||||
SessionId = sessionId,
|
||||
StartedAt = DateTime.UtcNow.AddMinutes(-5),
|
||||
FinishedAt = DateTime.UtcNow.AddMinutes(-1),
|
||||
ExitCode = 0,
|
||||
ResultMarkdown = "first result",
|
||||
});
|
||||
return taskId;
|
||||
}
|
||||
|
||||
// Bug: SeedAsync used to run after the Running claim but before ContinueAsync's own
|
||||
// try/catch started, so a throw here propagated straight out of ContinueAsync to the
|
||||
// caller (OverrideSlotService.RunContinueInSlotAsync), which only logs — leaving the task
|
||||
// stuck Running forever.
|
||||
[Fact]
|
||||
public async Task ContinueAsync_SkillSeederThrowsAfterClaim_MarksTaskFailed_NotStuckRunning()
|
||||
{
|
||||
var taskId = await SeedContinuableTaskAsync("sess-seed-throws");
|
||||
var throwingSeeder = new FakeSessionSkillSeeder { ThrowOnSeed = new InvalidOperationException("skill seed exploded") };
|
||||
|
||||
using var ctx2 = _db.CreateContext();
|
||||
var runner = BuildRunner(new FakeClaudeProcess(), ctx2, skillSeeder: throwingSeeder);
|
||||
|
||||
await runner.ContinueAsync(taskId, "please continue", "slot-1", CancellationToken.None);
|
||||
|
||||
using var verify = _db.CreateContext();
|
||||
var task = await new TaskRepository(verify).GetByIdAsync(taskId);
|
||||
Assert.NotNull(task);
|
||||
Assert.Equal(TaskStatus.Failed, task.Status);
|
||||
}
|
||||
|
||||
// Bug: SetupMcpConfigAsync registers the per-run MCP token *before* it can fail (writing
|
||||
// the config file to disk). When it threw after Register but before the try/catch existed,
|
||||
// the token was never unregistered — a leak in TaskRunTokenRegistry on top of the stuck
|
||||
// Running task.
|
||||
[Fact]
|
||||
public async Task ContinueAsync_McpSetupThrowsAfterTokenRegistered_MarksTaskFailed_AndUnregistersToken()
|
||||
{
|
||||
var taskId = await SeedContinuableTaskAsync("sess-mcp-throws");
|
||||
|
||||
// SetupMcpConfigAsync calls Directory.CreateDirectory(cfg.LogRoot) right after
|
||||
// registering the token; pointing LogRoot at an existing file makes that call throw.
|
||||
var badLogRoot = Path.Combine(_tempDir, "logroot_is_a_file");
|
||||
await File.WriteAllTextAsync(badLogRoot, "not a directory");
|
||||
var cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = badLogRoot };
|
||||
var tokens = new TaskRunTokenRegistry();
|
||||
|
||||
using var ctx2 = _db.CreateContext();
|
||||
var runner = BuildRunner(new FakeClaudeProcess(), ctx2, tokens: tokens, cfg: cfg);
|
||||
|
||||
await runner.ContinueAsync(taskId, "please continue", "slot-1", CancellationToken.None);
|
||||
|
||||
using var verify = _db.CreateContext();
|
||||
var task = await new TaskRepository(verify).GetByIdAsync(taskId);
|
||||
Assert.NotNull(task);
|
||||
Assert.Equal(TaskStatus.Failed, task.Status);
|
||||
Assert.False(tokens.HasAnyTokenFor(taskId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user