feat(daily-prep): run daily prep from PrimeRunner via allowed MCP tools

This commit is contained in:
mika kuns
2026-06-03 16:24:09 +02:00
parent fd7f8ac78f
commit 20b3a29d08
4 changed files with 187 additions and 20 deletions

View File

@@ -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.
""";
}

View File

@@ -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<PrimeRunner> _logger;
private static readonly TimeSpan FireTimeout = TimeSpan.FromMinutes(5);
private const int MaxTurns = 30;
public PrimeRunner(IClaudeProcess claude, ILogger<PrimeRunner> logger)
private readonly IClaudeProcess _claude;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IPrimeClock _clock;
private readonly ILogger<PrimeRunner> _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
public PrimeRunner(
IClaudeProcess claude,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
IPrimeClock clock,
ILogger<PrimeRunner> logger)
{
_claude = claude;
_dbFactory = dbFactory;
_clock = clock;
_logger = logger;
}
public async Task<PrimeRunOutcome> FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
{
if (!await _gate.WaitAsync(0, ct))
return new PrimeRunOutcome(false, "Daily prep already running");
try
{
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);
try
{
var prompt = schedule.PromptOverride ?? "ping";
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}";
}

View File

@@ -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);
}
}

View File

@@ -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<RunResult> RunAsync(
string arguments,
string prompt,
string workingDirectory,
Func<string, Task> 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<PrimeRunner>.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;
}
}