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.
128 lines
4.7 KiB
C#
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,
|
|
};
|
|
}
|