diff --git a/src/ClaudeDo.Worker/Prime/PrimePrompts.cs b/src/ClaudeDo.Worker/Prime/PrimePrompts.cs new file mode 100644 index 00000000..a1533344 --- /dev/null +++ b/src/ClaudeDo.Worker/Prime/PrimePrompts.cs @@ -0,0 +1,31 @@ +namespace ClaudeDo.Worker.Prime; + +/// Prompts and CLI args for the non-daily-prep Prime actions. +/// The daily-prep ones live in . +public static class PrimePrompts +{ + /// The whole point of a ping is to open the usage window, so it asks for the + /// cheapest possible answer. Not user-editable — a schedule's prompt override is ignored + /// for this kind. + public const string PingPrompt = "Reply with the single word: ok"; + + /// No tools at all: --strict-mcp-config without a --mcp-config argument loads no + /// MCP server, and one turn leaves no room to act on a tool result anyway. + public static IReadOnlyList BuildPingArgs() => + [ + "-p", "--output-format", "stream-json", "--verbose", + "--permission-mode", "default", + "--max-turns", "1", + "--strict-mcp-config", + ]; + + /// ClaudeDo's MCP server only — no Read/Write/Edit/Bash. An unattended 07:00 run + /// may create and queue tasks; it may not touch the filesystem. + public static IReadOnlyList BuildCustomArgs(int maxTurns) => + [ + "-p", "--output-format", "stream-json", "--verbose", + "--permission-mode", "acceptEdits", + "--max-turns", maxTurns.ToString(), + "--allowedTools", "mcp__claudedo", + ]; +} diff --git a/src/ClaudeDo.Worker/Prime/PrimeRunner.cs b/src/ClaudeDo.Worker/Prime/PrimeRunner.cs index 35d46bb6..91616c2e 100644 --- a/src/ClaudeDo.Worker/Prime/PrimeRunner.cs +++ b/src/ClaudeDo.Worker/Prime/PrimeRunner.cs @@ -1,4 +1,5 @@ using ClaudeDo.Data; +using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Runner; using Microsoft.EntityFrameworkCore; @@ -33,9 +34,27 @@ public sealed class PrimeRunner : IPrimeRunner public async Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct) { + // One gate for all kinds — two schedules must never run at the same time. if (!await _gate.WaitAsync(0, ct)) - return new PrimeRunOutcome(false, "Daily prep already running"); + return new PrimeRunOutcome(false, "Prime run already running"); + try + { + return schedule.Kind switch + { + PrimeActionKind.FillMyDay => await RunFillMyDayAsync(schedule, ct), + PrimeActionKind.Custom => await RunCustomAsync(schedule, ct), + _ => await RunPingAsync(ct), + }; + } + finally + { + _gate.Release(); + } + } + + private async Task RunFillMyDayAsync(PrimeScheduleDto schedule, CancellationToken ct) + { var success = false; try { @@ -45,8 +64,7 @@ public sealed class PrimeRunner : IPrimeRunner await _broadcaster.PrepStartedAsync(); - var cwd = Paths.AppDataRoot(); - Directory.CreateDirectory(cwd); + var cwd = Cwd(); int maxTasks; await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct)) @@ -57,13 +75,12 @@ public sealed class PrimeRunner : IPrimeRunner var today = DateOnly.FromDateTime(_clock.Now.LocalDateTime); var prompt = DailyPrepPrompt.BuildPrompt(maxTasks, today, schedule.PromptOverride); - var args = DailyPrepPrompt.BuildArgs(MaxTurns); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(FireTimeout); var result = await _claude.RunAsync( - arguments: args, + arguments: DailyPrepPrompt.BuildArgs(MaxTurns), prompt: prompt, workingDirectory: cwd, onStdoutLine: async line => @@ -90,7 +107,57 @@ public sealed class PrimeRunner : IPrimeRunner finally { await _broadcaster.PrepFinishedAsync(success); - _gate.Release(); } } + + private Task RunPingAsync(CancellationToken ct) => + RunQuietAsync(PrimePrompts.BuildPingArgs(), PrimePrompts.PingPrompt, "Ping complete", "Ping", ct); + + private Task RunCustomAsync(PrimeScheduleDto schedule, CancellationToken ct) + { + var prompt = schedule.PromptOverride; + if (string.IsNullOrWhiteSpace(prompt)) + return Task.FromResult(new PrimeRunOutcome(false, "Custom schedule has no prompt")); + + return RunQuietAsync(PrimePrompts.BuildCustomArgs(MaxTurns), prompt, "Custom run complete", "Custom", ct); + } + + /// Ping and Custom deliberately write no log file and raise no Prep* events — the + /// prep log belongs to the MyDay selection and would otherwise be overwritten with noise. + private async Task RunQuietAsync( + IReadOnlyList args, string prompt, string successMessage, string label, CancellationToken ct) + { + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(FireTimeout); + + var result = await _claude.RunAsync( + arguments: args, + prompt: prompt, + workingDirectory: Cwd(), + onStdoutLine: _ => Task.CompletedTask, + ct: timeoutCts.Token); + + return result.IsSuccess + ? new PrimeRunOutcome(true, successMessage) + : new PrimeRunOutcome(false, $"exit code {result.ExitCode}"); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalMinutes:0} min"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Prime {Label} run failed", label); + return new PrimeRunOutcome(false, ex.Message); + } + } + + private static string Cwd() + { + var cwd = Paths.AppDataRoot(); + Directory.CreateDirectory(cwd); + return cwd; + } } diff --git a/src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs b/src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs index ec138255..0719eb99 100644 --- a/src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs +++ b/src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs @@ -1,3 +1,5 @@ +using ClaudeDo.Data.Models; + namespace ClaudeDo.Worker.Prime; public sealed record PrimeScheduleDto( @@ -6,4 +8,5 @@ public sealed record PrimeScheduleDto( TimeSpan TimeOfDay, bool Enabled, DateTimeOffset? LastRunAt, - string? PromptOverride); + string? PromptOverride, + PrimeActionKind Kind); diff --git a/src/ClaudeDo.Worker/Prime/PrimeScheduler.cs b/src/ClaudeDo.Worker/Prime/PrimeScheduler.cs index 2e7b2543..0229c13a 100644 --- a/src/ClaudeDo.Worker/Prime/PrimeScheduler.cs +++ b/src/ClaudeDo.Worker/Prime/PrimeScheduler.cs @@ -102,7 +102,7 @@ public sealed class PrimeScheduler : BackgroundService } private static PrimeScheduleDto ToDto(Data.Models.PrimeScheduleEntity e) => - new(e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride); + new(e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride, e.Kind); private async Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct) { diff --git a/tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs b/tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs index e7acdc30..0e7b95f6 100644 --- a/tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs @@ -1,3 +1,4 @@ +using ClaudeDo.Data.Models; using ClaudeDo.Worker.Prime; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; @@ -22,6 +23,9 @@ public class PrimeRunnerTests : IDisposable private readonly string[] _emitLines; private readonly string? _result; + public IReadOnlyList? LastArguments { get; private set; } + public string? LastPrompt { get; private set; } + public FakeClaudeProcess(TimeSpan delay = default, int exitCode = 0, string[]? emitLines = null, string? result = null) { _delay = delay; @@ -37,6 +41,9 @@ public class PrimeRunnerTests : IDisposable Func onStdoutLine, CancellationToken ct) { + LastArguments = arguments; + LastPrompt = prompt; + if (_delay > TimeSpan.Zero) await Task.Delay(_delay, ct); @@ -72,7 +79,10 @@ public class PrimeRunnerTests : IDisposable broadcaster); private static PrimeScheduleDto DefaultSchedule() => - new(Guid.Empty, 0, TimeSpan.Zero, true, null, null); + new(Guid.Empty, 0, TimeSpan.Zero, true, null, null, PrimeActionKind.FillMyDay); + + private static PrimeScheduleDto Schedule(PrimeActionKind kind, string? promptOverride = null) => + new(Guid.Empty, 0, TimeSpan.Zero, true, null, promptOverride, kind); [Fact] public async Task FireAsync_returns_success_when_claude_exits_zero() @@ -114,7 +124,7 @@ public class PrimeRunnerTests : IDisposable var broadcaster = new RecordingPrimeBroadcaster(); var claude = new FakeClaudeProcess(emitLines: ["{\"a\":1}", "{\"b\":2}"], exitCode: 0, result: "ok"); var runner = NewRunner(claude, broadcaster); - var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null); + var schedule = DefaultSchedule(); var outcome = await runner.FireAsync(schedule, CancellationToken.None); @@ -133,7 +143,7 @@ public class PrimeRunnerTests : IDisposable var claude = new FakeClaudeProcess(emitLines: new[] { "lineA", "lineB" }, exitCode: 0, result: "ok"); var runner = NewRunner(claude, new RecordingPrimeBroadcaster()); - await runner.FireAsync(new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null), CancellationToken.None); + await runner.FireAsync(DefaultSchedule(), CancellationToken.None); var contents = await File.ReadAllTextAsync(path); Assert.Contains("lineA", contents); @@ -142,9 +152,105 @@ public class PrimeRunnerTests : IDisposable // Truncation: a second run with different lines replaces the file. var claude2 = new FakeClaudeProcess(emitLines: new[] { "lineC" }, exitCode: 0, result: "ok"); var runner2 = NewRunner(claude2, new RecordingPrimeBroadcaster()); - await runner2.FireAsync(new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null), CancellationToken.None); + await runner2.FireAsync(DefaultSchedule(), CancellationToken.None); var after = await File.ReadAllTextAsync(path); Assert.DoesNotContain("lineA", after); Assert.Contains("lineC", after); } + + [Fact] + public async Task Ping_runs_a_single_turn_with_no_mcp_servers() + { + var claude = new FakeClaudeProcess(exitCode: 0, result: "ok"); + var runner = NewRunner(claude, new RecordingPrimeBroadcaster()); + + var outcome = await runner.FireAsync(Schedule(PrimeActionKind.Ping), CancellationToken.None); + + Assert.True(outcome.Success); + Assert.Contains("--strict-mcp-config", claude.LastArguments!); + var turnsIndex = claude.LastArguments!.ToList().IndexOf("--max-turns"); + Assert.Equal("1", claude.LastArguments[turnsIndex + 1]); + Assert.DoesNotContain(DailyPrepPrompt.SetMyDayTool, claude.LastArguments); + } + + [Fact] + public async Task Ping_writes_no_prep_log_and_raises_no_prep_events() + { + var path = DailyPrepPrompt.LogPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, "previous fill-my-day run"); + + var broadcaster = new RecordingPrimeBroadcaster(); + var claude = new FakeClaudeProcess(emitLines: ["ping-line"], exitCode: 0, result: "ok"); + var runner = NewRunner(claude, broadcaster); + + await runner.FireAsync(Schedule(PrimeActionKind.Ping), CancellationToken.None); + + Assert.Equal("previous fill-my-day run", await File.ReadAllTextAsync(path)); + Assert.Equal(0, broadcaster.StartedCount); + Assert.Empty(broadcaster.Lines); + Assert.Empty(broadcaster.FinishedResults); + } + + [Fact] + public async Task Custom_sends_the_prompt_override_verbatim_with_claudedo_mcp_only() + { + var claude = new FakeClaudeProcess(exitCode: 0, result: "ok"); + var runner = NewRunner(claude, new RecordingPrimeBroadcaster()); + + var outcome = await runner.FireAsync( + Schedule(PrimeActionKind.Custom, "Queue the three oldest idle tasks."), + CancellationToken.None); + + Assert.True(outcome.Success); + Assert.Equal("Queue the three oldest idle tasks.", claude.LastPrompt); + Assert.Contains("--allowedTools", claude.LastArguments!); + Assert.Contains("mcp__claudedo", claude.LastArguments!); + Assert.DoesNotContain("--strict-mcp-config", claude.LastArguments!); + } + + [Fact] + public async Task Custom_writes_no_prep_log_and_raises_no_prep_events() + { + var path = DailyPrepPrompt.LogPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, "previous fill-my-day run"); + + var broadcaster = new RecordingPrimeBroadcaster(); + var claude = new FakeClaudeProcess(emitLines: ["custom-line"], exitCode: 0, result: "ok"); + var runner = NewRunner(claude, broadcaster); + + await runner.FireAsync(Schedule(PrimeActionKind.Custom, "do a thing"), CancellationToken.None); + + Assert.Equal("previous fill-my-day run", await File.ReadAllTextAsync(path)); + Assert.Equal(0, broadcaster.StartedCount); + Assert.Empty(broadcaster.FinishedResults); + } + + [Fact] + public async Task FillMyDay_still_appends_the_prompt_override() + { + var claude = new FakeClaudeProcess(exitCode: 0, result: "ok"); + var runner = NewRunner(claude, new RecordingPrimeBroadcaster()); + + await runner.FireAsync( + Schedule(PrimeActionKind.FillMyDay, "Prefer the LogX tasks."), + CancellationToken.None); + + Assert.Contains("Prefer the LogX tasks.", claude.LastPrompt); + Assert.Contains(DailyPrepPrompt.SetMyDayTool, claude.LastArguments!); + } + + [Fact] + public async Task Gate_is_shared_across_kinds() + { + var runner = NewRunner(claudeDelay: TimeSpan.FromSeconds(2)); + + var first = runner.FireAsync(Schedule(PrimeActionKind.Ping), CancellationToken.None); + var second = await runner.FireAsync(Schedule(PrimeActionKind.FillMyDay), CancellationToken.None); + + Assert.False(second.Success); + Assert.Contains("already running", second.Message, StringComparison.OrdinalIgnoreCase); + await first; + } }