Eine List-Handler-ConPTY-Session, die mitten in einer Phase stirbt, war bisher
ein stilles totes Tile: InteractiveTerminalViewModel.OnSessionProcessExited
setzte nur bei einem Tod innerhalb des 5s-Grace-Fensters ein sichtbares Banner.
Neu:
- HandlerRoundState (JSON unter ~/.claudeDo/handler-rounds.json, keine
EF-Migration): Phase + Task-Set der laufenden Runde, geschrieben von
BuildForMergeHelperAsync ("triage") und BuildForMergeHelperHandoffAsync.
Ohne das existieren beide nur im transienten HandoffRequested-Broadcast.
- HandlerRestartCoordinator: entscheidet den Neustart, fail-closed. Beleg fuer
"war das Usage-Limit" ist FiveHour.Utilization >= 95 zum Todeszeitpunkt --
eine ConPTY-Session hat kein stdout, TaskRunner.IsUsageLimitMessage hat hier
nichts zu lesen. Nur bei PowerUse (beide Keys), max ein Neustart pro Runde,
Warten bis FiveHour.ResetsAt, dann HandoffRequested erneut broadcasten.
Verbraucht kein Handoff-Budget (HandoffRoundTracker.Register wird nicht
angefasst). WorkerLog-Zeilen beim Tod und beim Neustart.
- Ui meldet den Exit-Code (nur dort bekannt) via
WorkerHub.ReportInteractiveSessionExited, ohne selbst zu filtern.
Sibling zu UsageLimitAutoContinueCoordinator, kein Umbau davon: der sieht nur
headless Runs.
139 lines
5.8 KiB
C#
139 lines
5.8 KiB
C#
using System.Text.Json;
|
|
using ClaudeDo.Data;
|
|
|
|
namespace ClaudeDo.Worker.Runner;
|
|
|
|
/// <summary>One list-handler round: the phase a session is running and the exact task set it was
|
|
/// handed. Written every time a handler session is built, so an outside observer (a crashed tile,
|
|
/// a deferred handoff) can reopen the SAME phase with the SAME set without the in-flight
|
|
/// <c>HandoffRequested</c> broadcast that is the only other place these ids exist.</summary>
|
|
/// <param name="Phase">A <see cref="MergeHelperPhase"/> value, or "triage" for the initial session
|
|
/// (which has no handoff spec and therefore cannot be reopened — see HandlerRestartCoordinator).</param>
|
|
/// <param name="AutoRestartUsed">Claimed once per round by HandlerRestartCoordinator, so a handler
|
|
/// that dies again after its auto-restart stays dead instead of relaunching all night.</param>
|
|
/// <param name="DeferredHandoffAt">Reserved for the "hand off cleanly before the usage limit"
|
|
/// follow-up, which parks a handoff until the 5h reset and needs to survive a worker restart.</param>
|
|
public sealed record HandlerRound(
|
|
string HandlerTaskId,
|
|
string Phase,
|
|
IReadOnlyList<string> TaskIds,
|
|
DateTime StartedAtUtc,
|
|
bool AutoRestartUsed = false,
|
|
DateTimeOffset? DeferredHandoffAt = null);
|
|
|
|
/// <summary>
|
|
/// File-backed store for <see cref="HandlerRound"/>, one entry per handler task. Deliberately a
|
|
/// JSON file under <c>~/.claudeDo/</c> (same Load/Save shape as <c>ClaudeDo.Ui.AppSettings</c>)
|
|
/// rather than a DB column: rounds are process-lifetime scratch state that no query joins against,
|
|
/// and an EF migration here would collide with the one the settings work owns this round.
|
|
/// Every read/write goes through one lock and re-serializes the whole file — there are at most a
|
|
/// handful of concurrent handler runs, so nothing here needs to be clever.
|
|
/// </summary>
|
|
public sealed class HandlerRoundState
|
|
{
|
|
// Entries older than this are dropped on the next save. A round that has been open for a week
|
|
// is not coming back, and without a sweep the file only ever grows.
|
|
private static readonly TimeSpan Retention = TimeSpan.FromDays(7);
|
|
|
|
private readonly string _path;
|
|
private readonly object _lock = new();
|
|
private Dictionary<string, HandlerRound>? _rounds;
|
|
|
|
public HandlerRoundState() : this(Path.Combine(Paths.AppDataRoot(), "handler-rounds.json"))
|
|
{
|
|
}
|
|
|
|
/// <summary>Test seam — point the store at a temp file instead of the live app data root.</summary>
|
|
internal HandlerRoundState(string path) => _path = path;
|
|
|
|
public HandlerRound? Get(string handlerTaskId)
|
|
{
|
|
lock (_lock)
|
|
return Rounds().GetValueOrDefault(handlerTaskId);
|
|
}
|
|
|
|
/// <summary>Overwrites this handler task's round. <see cref="HandlerRound.AutoRestartUsed"/> is
|
|
/// carried over when the phase is unchanged: the auto-restart itself rebuilds the same phase's
|
|
/// launch spec, which lands right back here, and a naive overwrite would hand the round a fresh
|
|
/// restart budget every time — exactly the all-night relaunch loop the claim exists to prevent.
|
|
/// A genuinely new phase is a new round and starts with a fresh budget.</summary>
|
|
public void Save(HandlerRound round)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var rounds = Rounds();
|
|
if (rounds.TryGetValue(round.HandlerTaskId, out var previous)
|
|
&& string.Equals(previous.Phase, round.Phase, StringComparison.Ordinal)
|
|
&& previous.AutoRestartUsed)
|
|
{
|
|
round = round with { AutoRestartUsed = true };
|
|
}
|
|
|
|
rounds[round.HandlerTaskId] = round;
|
|
Persist(rounds);
|
|
}
|
|
}
|
|
|
|
/// <summary>Takes this round's single auto-restart budget. False when there is no round for the
|
|
/// task or its budget is already spent — the same "never twice" discipline
|
|
/// <c>TaskRepository.TryClaimUsageLimitAutoContinueAsync</c> enforces for headless runs.</summary>
|
|
public bool TryClaimAutoRestart(string handlerTaskId)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var rounds = Rounds();
|
|
if (!rounds.TryGetValue(handlerTaskId, out var round) || round.AutoRestartUsed) return false;
|
|
|
|
rounds[handlerTaskId] = round with { AutoRestartUsed = true };
|
|
Persist(rounds);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
public void Remove(string handlerTaskId)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var rounds = Rounds();
|
|
if (rounds.Remove(handlerTaskId)) Persist(rounds);
|
|
}
|
|
}
|
|
|
|
// Callers hold _lock.
|
|
private Dictionary<string, HandlerRound> Rounds()
|
|
{
|
|
if (_rounds is not null) return _rounds;
|
|
try
|
|
{
|
|
if (File.Exists(_path))
|
|
{
|
|
_rounds = JsonSerializer.Deserialize<Dictionary<string, HandlerRound>>(
|
|
File.ReadAllText(_path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// A corrupt/unreadable file must never block a handler launch — start empty.
|
|
}
|
|
return _rounds ??= new Dictionary<string, HandlerRound>(StringComparer.Ordinal);
|
|
}
|
|
|
|
private void Persist(Dictionary<string, HandlerRound> rounds)
|
|
{
|
|
var cutoff = DateTime.UtcNow - Retention;
|
|
foreach (var stale in rounds.Where(kv => kv.Value.StartedAtUtc < cutoff).Select(kv => kv.Key).ToList())
|
|
rounds.Remove(stale);
|
|
|
|
try
|
|
{
|
|
var dir = Path.GetDirectoryName(_path);
|
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
|
File.WriteAllText(_path, JsonSerializer.Serialize(rounds, new JsonSerializerOptions { WriteIndented = true }));
|
|
}
|
|
catch
|
|
{
|
|
// Losing persistence costs an auto-restart across a worker restart, never a launch.
|
|
}
|
|
}
|
|
}
|