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. } } }