feat(daily-prep): run daily prep from PrimeRunner via allowed MCP tools
This commit is contained in:
27
src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs
Normal file
27
src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs
Normal 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.
|
||||||
|
""";
|
||||||
|
}
|
||||||
@@ -1,53 +1,80 @@
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Runner;
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Prime;
|
namespace ClaudeDo.Worker.Prime;
|
||||||
|
|
||||||
public sealed class PrimeRunner : IPrimeRunner
|
public sealed class PrimeRunner : IPrimeRunner
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan FireTimeout = TimeSpan.FromSeconds(60);
|
private static readonly TimeSpan FireTimeout = TimeSpan.FromMinutes(5);
|
||||||
private readonly IClaudeProcess _claude;
|
private const int MaxTurns = 30;
|
||||||
private readonly ILogger<PrimeRunner> _logger;
|
|
||||||
|
|
||||||
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;
|
_claude = claude;
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_clock = clock;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PrimeRunOutcome> FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
|
public async Task<PrimeRunOutcome> FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var cwd = Paths.AppDataRoot();
|
if (!await _gate.WaitAsync(0, ct))
|
||||||
Directory.CreateDirectory(cwd);
|
return new PrimeRunOutcome(false, "Daily prep already running");
|
||||||
|
|
||||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
||||||
timeoutCts.CancelAfter(FireTimeout);
|
|
||||||
|
|
||||||
try
|
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(
|
var result = await _claude.RunAsync(
|
||||||
arguments: "-p --max-turns 1",
|
arguments: args,
|
||||||
prompt: prompt,
|
prompt: prompt,
|
||||||
workingDirectory: cwd,
|
workingDirectory: cwd,
|
||||||
onStdoutLine: _ => Task.CompletedTask,
|
onStdoutLine: _ => Task.CompletedTask,
|
||||||
ct: timeoutCts.Token);
|
ct: timeoutCts.Token);
|
||||||
|
|
||||||
if (IsSuccess(result))
|
return result.IsSuccess
|
||||||
return new PrimeRunOutcome(true, "Primed Claude");
|
? new PrimeRunOutcome(true, "Daily prep complete")
|
||||||
return new PrimeRunOutcome(false, FailureMessage(result));
|
: 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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Prime fire failed");
|
_logger.LogWarning(ex, "Daily prep run failed");
|
||||||
return new PrimeRunOutcome(false, ex.Message);
|
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}";
|
|
||||||
}
|
}
|
||||||
|
|||||||
27
tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs
Normal file
27
tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs
Normal file
86
tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user