Measured 2026-09-16 against the live endpoint: a clean burst allows exactly 5 requests before a 429, with Retry-After: 300 (a flat 5-req/5-min quota, no ratelimit-* headers on either the 200s or the 429s). Lowered UsagePollIntervalActiveSeconds/UsagePollIntervalIdleSeconds default from 300/900 to 120 each (clamp floor 90, up from 60) — leaves ~2.5 requests of headroom per window for manual refreshes and any other consumer of the same OAuth token, unlike 60s which consumes the whole quota by itself. The quota is a flat per-account limit, not tied to ClaudeDo's own running/idle state, so both keys share the same default now (kept separate for config compatibility). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
154 lines
6.4 KiB
C#
154 lines
6.4 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; } = "~/.claudeDo/todo.db";
|
|
|
|
[JsonPropertyName("sandbox_root")]
|
|
public string SandboxRoot { get; set; } = "~/.claudeDo/sandbox";
|
|
|
|
[JsonPropertyName("log_root")]
|
|
public string LogRoot { get; set; } = "~/.claudeDo/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; } = "~/.claudeDo/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 90s on load. Measured 2026-09-16 against the live endpoint: a clean burst allows
|
|
/// exactly 5 requests before a 429, with <c>Retry-After: 300</c> (a flat 5-req/5-min quota,
|
|
/// no <c>ratelimit-*</c> headers). 120s leaves ~2.5 requests of headroom per 5-minute window
|
|
/// for manual refreshes and any other consumer of the same OAuth token — 60s consumes the
|
|
/// whole quota by itself with zero margin, which is what made the old flat-60s cadence 429
|
|
/// (see commit 2700c3d8). Same default as <see cref="UsagePollIntervalIdleSeconds"/>: the
|
|
/// quota is a flat per-account limit, not tied to ClaudeDo's own activity, so an active/idle
|
|
/// split buys nothing safety-wise. The two config keys stay separate for compatibility.
|
|
/// </summary>
|
|
[JsonPropertyName("usage_poll_interval_active_seconds")]
|
|
public int UsagePollIntervalActiveSeconds { get; set; } = 120;
|
|
|
|
/// <summary>
|
|
/// Usage-monitor poll interval while nothing is running. Clamped to a minimum of 90s on
|
|
/// load. Same default as <see cref="UsagePollIntervalActiveSeconds"/> — see that doc for the
|
|
/// measured rate limit this is sized against.
|
|
/// </summary>
|
|
[JsonPropertyName("usage_poll_interval_idle_seconds")]
|
|
public int UsagePollIntervalIdleSeconds { get; set; } = 120;
|
|
|
|
/// <summary>Base URL of the Bandel ticket-system API. Null = ticket integration off entirely.</summary>
|
|
[JsonPropertyName("ticket_api_base_url")]
|
|
public string? TicketApiBaseUrl { get; set; }
|
|
|
|
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(90, cfg.UsagePollIntervalActiveSeconds);
|
|
cfg.UsagePollIntervalIdleSeconds = Math.Max(90, 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)
|
|
=> SaveKey("online_inbox", JsonSerializer.SerializeToNode(OnlineInbox, InboxSerializerOpts), path);
|
|
|
|
/// <summary>Persists ONLY <c>claude_bin</c>, same read-modify-write as
|
|
/// <see cref="SaveOnlineInbox"/> — every other field in the file stays untouched.</summary>
|
|
public void SaveClaudeBin(string? path = null)
|
|
=> SaveKey("claude_bin", JsonValue.Create(ClaudeBin), path);
|
|
|
|
/// <summary>Persistiert NUR <c>ticket_api_base_url</c>, gleiches read-modify-write wie
|
|
/// <see cref="SaveClaudeBin"/> — jedes andere Feld der Datei bleibt unangetastet.</summary>
|
|
public void SaveTicketApiBaseUrl(string? path = null)
|
|
=> SaveKey("ticket_api_base_url", JsonValue.Create(TicketApiBaseUrl), path);
|
|
|
|
private static void SaveKey(string key, JsonNode? value, string? path)
|
|
{
|
|
path ??= DefaultConfigPath;
|
|
|
|
var root = File.Exists(path)
|
|
? JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject()
|
|
: new JsonObject();
|
|
|
|
root[key] = value;
|
|
|
|
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,
|
|
};
|
|
}
|