Merge claudedo/81b054800dc4446598685fd04fefddb5
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||||
> Last verified against commit `896d4b5` (2026-07-23).
|
> Last verified against commit `58741c2` (2026-08-06).
|
||||||
> Drift check: `git log --oneline 896d4b5..HEAD -- src/ClaudeDo.Worker`
|
> Drift check: `git log --oneline 58741c2..HEAD -- src/ClaudeDo.Worker`
|
||||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||||
|
|
||||||
# Worker: Task Execution Pipeline
|
# Worker: Task Execution Pipeline
|
||||||
@@ -108,7 +108,11 @@ read-only "## Reference files" section.
|
|||||||
- `TaskStateService` — all task status transitions; guards preconditions; signals queue/hub.
|
- `TaskStateService` — all task status transitions; guards preconditions; signals queue/hub.
|
||||||
|
|
||||||
**Lifecycle/** (startup recovery)
|
**Lifecycle/** (startup recovery)
|
||||||
- `StaleTaskRecovery` — tasks stuck Running after a crash/restart → Failed.
|
- `StaleTaskRecovery` — tasks stuck Running after a crash/restart → Failed. The underlying
|
||||||
|
`TaskStateService.RecoverStaleRunningAsync` bulk-flips Running→Failed, then re-runs the same
|
||||||
|
chain/parent-advance side effects as a normal `FailAsync` (per recovered id, best-effort) so a
|
||||||
|
crash mid-chain-child or mid-improvement-child doesn't leave a successor blocked forever or a
|
||||||
|
`WaitingForChildren` parent wedged.
|
||||||
- `OrphanRecovery` — dequeues children whose parent is no longer planning (stays attached).
|
- `OrphanRecovery` — dequeues children whose parent is no longer planning (stays attached).
|
||||||
- `AttachmentOrphanRecovery` — cleans orphaned attachment files.
|
- `AttachmentOrphanRecovery` — cleans orphaned attachment files.
|
||||||
- `TaskResetService` — manual reset to Idle.
|
- `TaskResetService` — manual reset to Idle.
|
||||||
|
|||||||
@@ -325,7 +325,9 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
|
|
||||||
// Unconditional status write — bypasses transition rules. Used by the UI's
|
// Unconditional status write — bypasses transition rules. Used by the UI's
|
||||||
// "set status freely" affordance; intentionally no guards (caller may strand
|
// "set status freely" affordance; intentionally no guards (caller may strand
|
||||||
// the runner if used while a task is executing).
|
// the runner if used while a task is executing). It also bypasses chain/parent
|
||||||
|
// advancement — forcing a chain child or a WaitingForChildren parent's child to a
|
||||||
|
// terminal status here does not unblock its successor or re-check the parent.
|
||||||
public async Task<TransitionResult> ForceSetStatusAsync(string taskId, TaskStatus status, CancellationToken ct)
|
public async Task<TransitionResult> ForceSetStatusAsync(string taskId, TaskStatus status, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
@@ -418,13 +420,32 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
{
|
{
|
||||||
var resultText = "[stale] " + reason;
|
var resultText = "[stale] " + reason;
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
List<string> recoveredIds;
|
||||||
return await ctx.Tasks
|
int affected;
|
||||||
.Where(t => t.Status == TaskStatus.Running)
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||||
.ExecuteUpdateAsync(s => s
|
{
|
||||||
.SetProperty(t => t.Status, TaskStatus.Failed)
|
recoveredIds = await ctx.Tasks
|
||||||
.SetProperty(t => t.FinishedAt, now)
|
.Where(t => t.Status == TaskStatus.Running)
|
||||||
.SetProperty(t => t.Result, resultText), ct);
|
.Select(t => t.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
affected = await ctx.Tasks
|
||||||
|
.Where(t => t.Status == TaskStatus.Running)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, TaskStatus.Failed)
|
||||||
|
.SetProperty(t => t.FinishedAt, now)
|
||||||
|
.SetProperty(t => t.Result, resultText), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A recovered task may have been a planning/improvement chain child or the last
|
||||||
|
// non-terminal child of a WaitingForChildren parent. The bulk flip above skips the
|
||||||
|
// usual terminal-transition side effects, so without this a crash mid-run would
|
||||||
|
// leave the chain successor blocked forever and the parent wedged in
|
||||||
|
// WaitingForChildren with nothing left to re-check it.
|
||||||
|
foreach (var taskId in recoveredIds)
|
||||||
|
await OnChildTerminalAsync(taskId, TaskStatus.Failed);
|
||||||
|
|
||||||
|
return affected;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A subtask is "draft" only while its planning parent has an open (Active) session.
|
// A subtask is "draft" only while its planning parent has an open (Active) session.
|
||||||
|
|||||||
@@ -456,6 +456,25 @@ public sealed class TaskStateServiceTests : IDisposable
|
|||||||
Assert.StartsWith("[stale] ", t.Result);
|
Assert.StartsWith("[stale] ", t.Result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RecoverStaleRunningAsync_ResolvesBlockedSuccessor_AndAdvancesParent()
|
||||||
|
{
|
||||||
|
var parent = await SeedTaskAsync(TaskStatus.WaitingForChildren, phase: PlanningPhase.Finalized);
|
||||||
|
var c0 = await SeedTaskAsync(TaskStatus.Running, parentId: parent, sortOrder: 0);
|
||||||
|
var c1 = await SeedTaskAsync(TaskStatus.Queued, parentId: parent, sortOrder: 1, blockedBy: c0);
|
||||||
|
|
||||||
|
var count = await _sut.RecoverStaleRunningAsync("worker restart", default);
|
||||||
|
|
||||||
|
Assert.Equal(1, count);
|
||||||
|
Assert.Equal(TaskStatus.Failed, await GetStatusAsync(c0));
|
||||||
|
// c1 was blocked on the crashed task; the chain coordinator resolves it (cancels it,
|
||||||
|
// since the predecessor didn't finish successfully) instead of leaving it Queued with
|
||||||
|
// a dangling BlockedByTaskId that the picker would skip forever.
|
||||||
|
Assert.Equal(TaskStatus.Cancelled, await GetStatusAsync(c1));
|
||||||
|
// Both children are now terminal, so the WaitingForChildren parent advances.
|
||||||
|
Assert.Equal(TaskStatus.WaitingForReview, await GetStatusAsync(parent));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Child terminal → chain advance ───────────────────────────────────
|
// ─── Child terminal → chain advance ───────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user