From 20b3a29d08e147042c184b165ad5e6ccfb7b79a1 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 3 Jun 2026 16:24:09 +0200 Subject: [PATCH] feat(daily-prep): run daily prep from PrimeRunner via allowed MCP tools --- src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs | 27 ++++++ src/ClaudeDo.Worker/Prime/PrimeRunner.cs | 67 ++++++++++----- .../Prime/DailyPrepPromptTests.cs | 27 ++++++ .../Prime/PrimeRunnerTests.cs | 86 +++++++++++++++++++ 4 files changed, 187 insertions(+), 20 deletions(-) create mode 100644 src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs diff --git a/src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs b/src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs new file mode 100644 index 0000000..b0595e4 --- /dev/null +++ b/src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs @@ -0,0 +1,27 @@ +namespace ClaudeDo.Worker.Prime; + +public static class DailyPrepPrompt +{ + public const string CandidatesTool = "mcp__claudedo__get_daily_prep_candidates"; + public const string SetMyDayTool = "mcp__claudedo__set_my_day"; + + public static string BuildArgs(int maxTurns) => + "-p --output-format stream-json --verbose --permission-mode acceptEdits " + + $"--max-turns {maxTurns} " + + $"--allowedTools {CandidatesTool} {SetMyDayTool}"; + + public static string BuildPrompt(int maxTasks, DateOnly today) => + $""" + Du bereitest meinen Arbeitstag fuer {today:yyyy-MM-dd} vor. + + 1. Rufe {CandidatesTool} auf. + 2. Behalte bereits als MyDay markierte offene Tasks (currentMyDay) — entferne sie nicht. + 3. Fuelle bis maximal {maxTasks} offene Tasks GESAMT in MyDay auf (currentMyDay zaehlt mit). Niemals mehr. + 4. Schaetze pro Kandidat grob den Aufwand und waehle eine machbare Mischung (nicht nur Grossbrocken). + Priorisiere isStarred, faellige (scheduledFor) und aeltere Tasks. + 5. Lege thematisch verwandte Tasks durch aufeinanderfolgende sortOrder-Werte nebeneinander. + 6. Setze die Auswahl via {SetMyDayTool}(taskId, true, sortOrder). Markiere nichts ausserhalb der Kandidatenliste. + + Wenn es keine Kandidaten gibt, tue nichts. + """; +} diff --git a/src/ClaudeDo.Worker/Prime/PrimeRunner.cs b/src/ClaudeDo.Worker/Prime/PrimeRunner.cs index 0c88eeb..41b0836 100644 --- a/src/ClaudeDo.Worker/Prime/PrimeRunner.cs +++ b/src/ClaudeDo.Worker/Prime/PrimeRunner.cs @@ -1,53 +1,80 @@ using ClaudeDo.Data; +using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Runner; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Prime; public sealed class PrimeRunner : IPrimeRunner { - private static readonly TimeSpan FireTimeout = TimeSpan.FromSeconds(60); - private readonly IClaudeProcess _claude; - private readonly ILogger _logger; + private static readonly TimeSpan FireTimeout = TimeSpan.FromMinutes(5); + private const int MaxTurns = 30; - public PrimeRunner(IClaudeProcess claude, ILogger logger) + private readonly IClaudeProcess _claude; + private readonly IDbContextFactory _dbFactory; + private readonly IPrimeClock _clock; + private readonly ILogger _logger; + private readonly SemaphoreSlim _gate = new(1, 1); + + public PrimeRunner( + IClaudeProcess claude, + IDbContextFactory dbFactory, + IPrimeClock clock, + ILogger logger) { _claude = claude; + _dbFactory = dbFactory; + _clock = clock; _logger = logger; } public async Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct) { - var cwd = Paths.AppDataRoot(); - Directory.CreateDirectory(cwd); - - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - timeoutCts.CancelAfter(FireTimeout); + if (!await _gate.WaitAsync(0, ct)) + return new PrimeRunOutcome(false, "Daily prep already running"); try { - var prompt = schedule.PromptOverride ?? "ping"; + var cwd = Paths.AppDataRoot(); + Directory.CreateDirectory(cwd); + + int maxTasks; + await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct)) + { + var settings = await new AppSettingsRepository(dbCtx).GetAsync(ct); + maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks; + } + + var today = DateOnly.FromDateTime(_clock.Now.LocalDateTime); + var prompt = DailyPrepPrompt.BuildPrompt(maxTasks, today); + var args = DailyPrepPrompt.BuildArgs(MaxTurns); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(FireTimeout); + var result = await _claude.RunAsync( - arguments: "-p --max-turns 1", + arguments: args, prompt: prompt, workingDirectory: cwd, onStdoutLine: _ => Task.CompletedTask, ct: timeoutCts.Token); - if (IsSuccess(result)) - return new PrimeRunOutcome(true, "Primed Claude"); - return new PrimeRunOutcome(false, FailureMessage(result)); + return result.IsSuccess + ? new PrimeRunOutcome(true, "Daily prep complete") + : new PrimeRunOutcome(false, $"exit code {result.ExitCode}"); } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested) + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { - return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalSeconds:0}s"); + return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalMinutes:0} min"); } catch (Exception ex) { - _logger.LogWarning(ex, "Prime fire failed"); + _logger.LogWarning(ex, "Daily prep run failed"); return new PrimeRunOutcome(false, ex.Message); } + finally + { + _gate.Release(); + } } - - private static bool IsSuccess(RunResult result) => result.IsSuccess; - private static string FailureMessage(RunResult result) => $"exit code {result.ExitCode}"; } diff --git a/tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs b/tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs new file mode 100644 index 0000000..3a871f1 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs @@ -0,0 +1,27 @@ +using ClaudeDo.Worker.Prime; + +namespace ClaudeDo.Worker.Tests.Prime; + +public class DailyPrepPromptTests +{ + [Fact] + public void Build_prompt_contains_cap_and_date() + { + var prompt = DailyPrepPrompt.BuildPrompt(maxTasks: 5, today: new DateOnly(2026, 6, 3)); + Assert.Contains("5", prompt); + Assert.Contains("2026-06-03", prompt); + Assert.Contains("get_daily_prep_candidates", prompt); + Assert.Contains("set_my_day", prompt); + } + + [Fact] + public void Build_args_allows_only_the_two_tools() + { + var args = DailyPrepPrompt.BuildArgs(maxTurns: 30); + Assert.Contains("--output-format stream-json", args); + Assert.Contains("--max-turns 30", args); + Assert.Contains("--allowedTools", args); + Assert.Contains("mcp__claudedo__get_daily_prep_candidates", args); + Assert.Contains("mcp__claudedo__set_my_day", args); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs b/tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs new file mode 100644 index 0000000..628cb83 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs @@ -0,0 +1,86 @@ +using ClaudeDo.Worker.Prime; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ClaudeDo.Worker.Tests.Prime; + +public class PrimeRunnerTests : IDisposable +{ + private readonly DbFixture _db = new(); + public void Dispose() => _db.Dispose(); + + private sealed class FakeClock : IPrimeClock + { + public DateTimeOffset Now { get; set; } = new DateTimeOffset(2026, 6, 3, 8, 0, 0, TimeSpan.FromHours(2)); + } + + private sealed class FakeClaudeProcess : IClaudeProcess + { + private readonly TimeSpan _delay; + private readonly int _exitCode; + + public FakeClaudeProcess(TimeSpan delay = default, int exitCode = 0) + { + _delay = delay; + _exitCode = exitCode; + } + + public async Task RunAsync( + string arguments, + string prompt, + string workingDirectory, + Func onStdoutLine, + CancellationToken ct) + { + if (_delay > TimeSpan.Zero) + await Task.Delay(_delay, ct); + + return new RunResult { ExitCode = _exitCode, ResultMarkdown = _exitCode == 0 ? "ok" : null }; + } + } + + private PrimeRunner NewRunner(TimeSpan claudeDelay = default, int exitCode = 0) => + new PrimeRunner( + new FakeClaudeProcess(claudeDelay, exitCode), + _db.CreateFactory(), + new FakeClock(), + NullLogger.Instance); + + private static PrimeScheduleDto DefaultSchedule() => + new(Guid.Empty, 0, TimeSpan.Zero, true, null, null); + + [Fact] + public async Task FireAsync_returns_success_when_claude_exits_zero() + { + var runner = NewRunner(); + var outcome = await runner.FireAsync(DefaultSchedule(), CancellationToken.None); + + Assert.True(outcome.Success); + Assert.Equal("Daily prep complete", outcome.Message); + } + + [Fact] + public async Task FireAsync_returns_failure_when_claude_exits_nonzero() + { + var runner = NewRunner(exitCode: 1); + var outcome = await runner.FireAsync(DefaultSchedule(), CancellationToken.None); + + Assert.False(outcome.Success); + Assert.Contains("exit code", outcome.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task FireAsync_returns_already_running_when_gate_held() + { + var runner = NewRunner(claudeDelay: TimeSpan.FromSeconds(2)); + var schedule = DefaultSchedule(); + + var first = runner.FireAsync(schedule, CancellationToken.None); + var second = await runner.FireAsync(schedule, CancellationToken.None); + + Assert.False(second.Success); + Assert.Contains("already running", second.Message, StringComparison.OrdinalIgnoreCase); + await first; + } +}