Files
ClaudeDo/src/ClaudeDo.Worker/Config/WorkerConfig.cs
T
mika kuns 2700c3d817 fix(usage): stop 429s with an activity-dependent poll cadence + manual refresh
The usage monitor polled the undocumented OAuth usage endpoint every 60s and
earned 429s. It now polls every 5 min while any task is Running and every
15 min while idle (usage_poll_interval_active_seconds / _idle_seconds, both
clamped to >= 60; the old single usage_poll_interval_seconds key is gone).

A 429 comes back as UsageRateLimitedException carrying Retry-After and adds
exponential backoff on top, capped at 30 min and never shorter than the normal
cadence; the strike count resets on the first success. The schedule arithmetic
is the pure static UsagePollSchedule.NextDelay.

Since the idle cadence is slow on purpose, WorkerHub.RefreshUsage drives
UsageMonitorService.RefreshNowAsync behind a Refresh now button in the Usage
Monitor modal: an out-of-band poll that pushes the loop's next-due time out so
no double poll follows, with a 10s cooldown so click-spam can't earn a 429.

Staleness now measures against the slower (idle) interval so an idle worker
isn't flagged stale just for not polling.
2026-08-05 16:40:34 +02:00

128 lines
4.7 KiB
C#

using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using ClaudeDo.Data;
using ClaudeDo.Worker.Online;
namespace ClaudeDo.Worker.Config;
public sealed class WorkerConfig
{
[JsonPropertyName("db_path")]
public string DbPath { get; set; } = "~/.todo-app/todo.db";
[JsonPropertyName("sandbox_root")]
public string SandboxRoot { get; set; } = "~/.todo-app/sandbox";
[JsonPropertyName("log_root")]
public string LogRoot { get; set; } = "~/.todo-app/logs";
/// <summary>"sibling" → place worktrees next to the target repo; "central" → under <see cref="CentralWorktreeRoot"/>.</summary>
[JsonPropertyName("worktree_root_strategy")]
public string WorktreeRootStrategy { get; set; } = "sibling";
[JsonPropertyName("central_worktree_root")]
public string CentralWorktreeRoot { get; set; } = "~/.todo-app/worktrees";
[JsonPropertyName("queue_backstop_interval_ms")]
public int QueueBackstopIntervalMs { get; set; } = 30_000;
[JsonPropertyName("signalr_port")]
public int SignalRPort { get; set; } = 47_821;
[JsonPropertyName("claude_bin")]
public string ClaudeBin { get; set; } = "claude";
/// <summary>Port for the external MCP endpoint. 0 disables the external listener entirely.</summary>
[JsonPropertyName("external_mcp_port")]
public int ExternalMcpPort { get; set; } = 47_822;
/// <summary>Optional API key clients must pass via X-ClaudeDo-Key header. Null/empty = loopback trust only.</summary>
[JsonPropertyName("external_mcp_api_key")]
public string? ExternalMcpApiKey { get; set; }
[JsonPropertyName("online_inbox")]
public OnlineInboxConfig OnlineInbox { get; set; } = new();
/// <summary>
/// Usage-monitor poll interval while at least one task is Running. Clamped to a minimum
/// of 60s on load — the endpoint rate-limits (429) on tighter polling.
/// </summary>
[JsonPropertyName("usage_poll_interval_active_seconds")]
public int UsagePollIntervalActiveSeconds { get; set; } = 300;
/// <summary>
/// Usage-monitor poll interval while nothing is running. Clamped to a minimum of 60s on load.
/// </summary>
[JsonPropertyName("usage_poll_interval_idle_seconds")]
public int UsagePollIntervalIdleSeconds { get; set; } = 900;
public static string DefaultConfigPath =>
Path.Combine(Paths.AppDataRoot(), "worker.config.json");
/// <summary>
/// Loads the config from <paramref name="path"/> (defaults to <see cref="DefaultConfigPath"/>).
/// Missing file → returns defaults. Resolves all path-typed fields to absolute paths.
/// </summary>
public static WorkerConfig Load(string? path = null)
{
path ??= DefaultConfigPath;
WorkerConfig cfg;
if (File.Exists(path))
{
var json = File.ReadAllText(path);
cfg = JsonSerializer.Deserialize<WorkerConfig>(json, JsonOpts)
?? throw new InvalidOperationException($"Failed to parse {path}");
}
else
{
cfg = new WorkerConfig();
}
cfg.DbPath = Paths.Expand(cfg.DbPath);
cfg.SandboxRoot = Paths.Expand(cfg.SandboxRoot);
cfg.LogRoot = Paths.Expand(cfg.LogRoot);
cfg.CentralWorktreeRoot = Paths.Expand(cfg.CentralWorktreeRoot);
cfg.UsagePollIntervalActiveSeconds = Math.Max(60, cfg.UsagePollIntervalActiveSeconds);
cfg.UsagePollIntervalIdleSeconds = Math.Max(60, cfg.UsagePollIntervalIdleSeconds);
return cfg;
}
/// <summary>
/// Persists ONLY the <c>online_inbox</c> section back to <paramref name="path"/>
/// (defaults to <see cref="DefaultConfigPath"/>) without rewriting any other fields.
/// Reads the existing JSON, replaces the <c>online_inbox</c> node, and writes back indented.
/// </summary>
public void SaveOnlineInbox(string? path = null)
{
path ??= DefaultConfigPath;
var root = File.Exists(path)
? JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject()
: new JsonObject();
root["online_inbox"] = JsonSerializer.SerializeToNode(OnlineInbox, InboxSerializerOpts);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, root.ToJsonString(WriteOpts));
}
private static readonly JsonSerializerOptions JsonOpts = new()
{
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
private static readonly JsonSerializerOptions InboxSerializerOpts = new()
{
WriteIndented = false,
};
private static readonly JsonSerializerOptions WriteOpts = new()
{
WriteIndented = true,
};
}