using System.Text.Json;
using ClaudeDo.Data;
namespace ClaudeDo.Worker.Runner;
/// 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
/// HandoffRequested broadcast that is the only other place these ids exist.
/// A value, or "triage" for the initial session
/// (which has no handoff spec and therefore cannot be reopened — see HandlerRestartCoordinator).
/// Claimed once per round by HandlerRestartCoordinator, so a handler
/// that dies again after its auto-restart stays dead instead of relaunching all night.
/// 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.
public sealed record HandlerRound(
string HandlerTaskId,
string Phase,
IReadOnlyList TaskIds,
DateTime StartedAtUtc,
bool AutoRestartUsed = false,
DateTimeOffset? DeferredHandoffAt = null);
///
/// File-backed store for , one entry per handler task. Deliberately a
/// JSON file under ~/.claudeDo/ (same Load/Save shape as ClaudeDo.Ui.AppSettings)
/// 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.
///
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? _rounds;
public HandlerRoundState() : this(Path.Combine(Paths.AppDataRoot(), "handler-rounds.json"))
{
}
/// Test seam — point the store at a temp file instead of the live app data root.
internal HandlerRoundState(string path) => _path = path;
public HandlerRound? Get(string handlerTaskId)
{
lock (_lock)
return Rounds().GetValueOrDefault(handlerTaskId);
}
/// Overwrites this handler task's round. 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.
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);
}
}
/// 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
/// TaskRepository.TryClaimUsageLimitAutoContinueAsync enforces for headless runs.
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 Rounds()
{
if (_rounds is not null) return _rounds;
try
{
if (File.Exists(_path))
{
_rounds = JsonSerializer.Deserialize>(
File.ReadAllText(_path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
}
catch
{
// A corrupt/unreadable file must never block a handler launch — start empty.
}
return _rounds ??= new Dictionary(StringComparer.Ordinal);
}
private void Persist(Dictionary 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.
}
}
}