diff --git a/src/ClaudeDo.Worker/Prime/PrimeRunner.cs b/src/ClaudeDo.Worker/Prime/PrimeRunner.cs index 91616c2e..eaa2f035 100644 --- a/src/ClaudeDo.Worker/Prime/PrimeRunner.cs +++ b/src/ClaudeDo.Worker/Prime/PrimeRunner.cs @@ -2,6 +2,7 @@ using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tickets; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Prime; @@ -16,6 +17,7 @@ public sealed class PrimeRunner : IPrimeRunner private readonly IPrimeClock _clock; private readonly ILogger _logger; private readonly IPrimeBroadcaster _broadcaster; + private readonly TicketSystemConfig _tickets; private readonly SemaphoreSlim _gate = new(1, 1); public PrimeRunner( @@ -23,13 +25,15 @@ public sealed class PrimeRunner : IPrimeRunner IDbContextFactory dbFactory, IPrimeClock clock, ILogger logger, - IPrimeBroadcaster broadcaster) + IPrimeBroadcaster broadcaster, + TicketSystemConfig tickets) { _claude = claude; _dbFactory = dbFactory; _clock = clock; _logger = logger; _broadcaster = broadcaster; + _tickets = tickets; } public async Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct) @@ -42,7 +46,8 @@ public sealed class PrimeRunner : IPrimeRunner { return schedule.Kind switch { - PrimeActionKind.FillMyDay => await RunFillMyDayAsync(schedule, ct), + PrimeActionKind.FillMyDay => await RunPrepAsync("Daily prep", log => FillMyDayStepAsync(schedule, log, ct)), + PrimeActionKind.ImportTickets => await RunImportTicketsAsync(schedule, ct), PrimeActionKind.Custom => await RunCustomAsync(schedule, ct), _ => await RunPingAsync(ct), }; @@ -53,7 +58,37 @@ public sealed class PrimeRunner : IPrimeRunner } } - private async Task RunFillMyDayAsync(PrimeScheduleDto schedule, CancellationToken ct) + /// Ticket import, then FillMyDay — one log, one PrepStarted/PrepFinished pair. A failed import never + /// blocks the day plan (the existing tasks are still worth planning); without a ticket system it is plain FillMyDay. + private Task RunImportTicketsAsync(PrimeScheduleDto schedule, CancellationToken ct) + { + if (!_tickets.IsConfigured) + return RunPrepAsync("Daily prep", log => FillMyDayStepAsync(schedule, log, ct)); + + return RunPrepAsync("Ticket import", async log => + { + PrimeRunOutcome import; + try + { + import = await RunLoggedAsync( + TicketImportPrompt.BuildArgs(), TicketImportPrompt.BuildPrompt(schedule.PromptOverride), + log, "Tickets imported", ct); + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + _logger.LogWarning(ex, "Ticket import step failed"); + import = new PrimeRunOutcome(false, ex.Message); + } + + var day = await FillMyDayStepAsync(schedule, log, ct); + return import.Success + ? day with { Message = $"Tickets imported; {day.Message}" } + : new PrimeRunOutcome(false, $"Ticket import failed ({import.Message}); {day.Message}"); + }); + } + + /// Truncates the prep log once and brackets with PrepStarted/PrepFinished. + private async Task RunPrepAsync(string label, Func> body) { var success = false; try @@ -64,44 +99,13 @@ public sealed class PrimeRunner : IPrimeRunner await _broadcaster.PrepStartedAsync(); - var cwd = 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, schedule.PromptOverride); - - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - timeoutCts.CancelAfter(FireTimeout); - - var result = await _claude.RunAsync( - arguments: DailyPrepPrompt.BuildArgs(MaxTurns), - prompt: prompt, - workingDirectory: cwd, - onStdoutLine: async line => - { - await logWriter.WriteLineAsync(line); - await _broadcaster.PrepLineAsync(line); - }, - ct: timeoutCts.Token); - - success = result.IsSuccess; - return success - ? new PrimeRunOutcome(true, "Daily prep complete") - : new PrimeRunOutcome(false, $"exit code {result.ExitCode}"); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalMinutes:0} min"); + var outcome = await body(logWriter); + success = outcome.Success; + return outcome; } catch (Exception ex) { - _logger.LogWarning(ex, "Daily prep run failed"); + _logger.LogWarning(ex, "{Label} run failed", label); return new PrimeRunOutcome(false, ex.Message); } finally @@ -110,6 +114,53 @@ public sealed class PrimeRunner : IPrimeRunner } } + /// The MyDay selection run, writing into an already-open prep log. + private async Task FillMyDayStepAsync(PrimeScheduleDto schedule, LogWriter log, CancellationToken ct) + { + 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); + return await RunLoggedAsync( + DailyPrepPrompt.BuildArgs(MaxTurns), + DailyPrepPrompt.BuildPrompt(maxTasks, today, schedule.PromptOverride), + log, "Daily prep complete", ct); + } + + /// One claude run streaming into the prep log and the UI; a timeout is an outcome, not an exception. + private async Task RunLoggedAsync( + IReadOnlyList args, string prompt, LogWriter log, string successMessage, CancellationToken ct) + { + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(FireTimeout); + + var result = await _claude.RunAsync( + arguments: args, + prompt: prompt, + workingDirectory: Cwd(), + onStdoutLine: async line => + { + await log.WriteLineAsync(line); + await _broadcaster.PrepLineAsync(line); + }, + 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"); + } + } + private Task RunPingAsync(CancellationToken ct) => RunQuietAsync(PrimePrompts.BuildPingArgs(), PrimePrompts.PingPrompt, "Ping complete", "Ping", ct); diff --git a/src/ClaudeDo.Worker/Prime/TicketImportPrompt.cs b/src/ClaudeDo.Worker/Prime/TicketImportPrompt.cs new file mode 100644 index 00000000..34d04da2 --- /dev/null +++ b/src/ClaudeDo.Worker/Prime/TicketImportPrompt.cs @@ -0,0 +1,28 @@ +using ClaudeDo.Data; + +namespace ClaudeDo.Worker.Prime; + +/// Prompt and CLI args for the ticket-import step of an ImportTickets Prime run. +public static class TicketImportPrompt +{ + public const string CandidatesTool = "mcp__claudedo__get_ticket_import_candidates"; + public const string ImportTool = "mcp__claudedo__import_ticket"; + + /// One import call per item, so the budget scales with the backlog — daily prep's 30 would cap it at ~25 items. + public const int MaxTurns = 60; + + public static IReadOnlyList BuildArgs() => + [ + "-p", "--output-format", "stream-json", "--verbose", + "--permission-mode", "acceptEdits", + "--max-turns", MaxTurns.ToString(), + "--allowedTools", CandidatesTool, ImportTool, + ]; + + /// A schedule's override is appended, never substituted — same rule as . + public static string BuildPrompt(string? promptOverride = null) + { + var prompt = PromptFiles.Render(PromptKind.TicketImport, new Dictionary()); + return string.IsNullOrWhiteSpace(promptOverride) ? prompt : $"{prompt}\n\n{promptOverride}"; + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs b/tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs index 0e7b95f6..70cb5b17 100644 --- a/tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs @@ -1,7 +1,10 @@ using ClaudeDo.Data.Models; +using ClaudeDo.Worker; +using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Prime; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Worker.Tickets; using Microsoft.Extensions.Logging.Abstractions; namespace ClaudeDo.Worker.Tests.Prime; @@ -9,7 +12,25 @@ namespace ClaudeDo.Worker.Tests.Prime; public class PrimeRunnerTests : IDisposable { private readonly DbFixture _db = new(); - public void Dispose() => _db.Dispose(); + private readonly string _patDir = Path.Combine(Path.GetTempPath(), "cdo-prime-pat-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + _db.Dispose(); + try { Directory.Delete(_patDir, recursive: true); } catch { /* best effort */ } + } + + private TicketSystemConfig Tickets(bool configured) + { + var pat = new TicketPatStore(new DpapiTokenStore(Path.Combine(_patDir, "ticket.pat"))); + var worker = new WorkerConfig(); + if (configured) + { + worker.TicketApiBaseUrl = "http://api.local"; + pat.Store.Save("tsp_x"); + } + return new TicketSystemConfig(worker, pat); + } private sealed class FakeClock : IPrimeClock { @@ -22,16 +43,20 @@ public class PrimeRunnerTests : IDisposable private readonly int _exitCode; private readonly string[] _emitLines; private readonly string? _result; + private readonly Func, int>? _exitCodeFor; public IReadOnlyList? LastArguments { get; private set; } public string? LastPrompt { get; private set; } + public List> AllArguments { get; } = []; - public FakeClaudeProcess(TimeSpan delay = default, int exitCode = 0, string[]? emitLines = null, string? result = null) + public FakeClaudeProcess(TimeSpan delay = default, int exitCode = 0, string[]? emitLines = null, string? result = null, + Func, int>? exitCodeFor = null) { _delay = delay; _exitCode = exitCode; _emitLines = emitLines ?? []; _result = result; + _exitCodeFor = exitCodeFor; } public async Task RunAsync( @@ -41,6 +66,7 @@ public class PrimeRunnerTests : IDisposable Func onStdoutLine, CancellationToken ct) { + AllArguments.Add(arguments); LastArguments = arguments; LastPrompt = prompt; @@ -50,7 +76,8 @@ public class PrimeRunnerTests : IDisposable foreach (var line in _emitLines) await onStdoutLine(line); - return new RunResult { ExitCode = _exitCode, ResultMarkdown = _result ?? (_exitCode == 0 ? "ok" : null) }; + var exitCode = _exitCodeFor?.Invoke(arguments) ?? _exitCode; + return new RunResult { ExitCode = exitCode, ResultMarkdown = _result ?? (exitCode == 0 ? "ok" : null) }; } } @@ -70,13 +97,14 @@ public class PrimeRunnerTests : IDisposable private PrimeRunner NewRunner(TimeSpan claudeDelay = default, int exitCode = 0) => NewRunner(new FakeClaudeProcess(claudeDelay, exitCode), new RecordingPrimeBroadcaster()); - private PrimeRunner NewRunner(FakeClaudeProcess claude, IPrimeBroadcaster broadcaster) => + private PrimeRunner NewRunner(FakeClaudeProcess claude, IPrimeBroadcaster broadcaster, bool ticketsConfigured = false) => new PrimeRunner( claude, _db.CreateFactory(), new FakeClock(), NullLogger.Instance, - broadcaster); + broadcaster, + Tickets(ticketsConfigured)); private static PrimeScheduleDto DefaultSchedule() => new(Guid.Empty, 0, TimeSpan.Zero, true, null, null, PrimeActionKind.FillMyDay); @@ -241,6 +269,66 @@ public class PrimeRunnerTests : IDisposable Assert.Contains(DailyPrepPrompt.SetMyDayTool, claude.LastArguments!); } + [Fact] + public async Task ImportTickets_runs_the_import_then_the_day_plan_in_one_prep_session() + { + var broadcaster = new RecordingPrimeBroadcaster(); + var claude = new FakeClaudeProcess(exitCode: 0, result: "ok"); + var runner = NewRunner(claude, broadcaster, ticketsConfigured: true); + + var outcome = await runner.FireAsync(Schedule(PrimeActionKind.ImportTickets), CancellationToken.None); + + Assert.True(outcome.Success); + Assert.Equal(2, claude.AllArguments.Count); + Assert.Contains(TicketImportPrompt.ImportTool, claude.AllArguments[0]); + Assert.Contains(DailyPrepPrompt.SetMyDayTool, claude.AllArguments[1]); + Assert.Equal(1, broadcaster.StartedCount); + Assert.Equal([true], broadcaster.FinishedResults); + } + + [Fact] + public async Task ImportTickets_failed_import_still_plans_the_day_and_reports_the_failure() + { + var broadcaster = new RecordingPrimeBroadcaster(); + var claude = new FakeClaudeProcess(exitCodeFor: args => args.Contains(TicketImportPrompt.ImportTool) ? 1 : 0); + var runner = NewRunner(claude, broadcaster, ticketsConfigured: true); + + var outcome = await runner.FireAsync(Schedule(PrimeActionKind.ImportTickets), CancellationToken.None); + + Assert.Equal(2, claude.AllArguments.Count); + Assert.False(outcome.Success); + Assert.Contains("Ticket import failed", outcome.Message); + Assert.Contains("Daily prep complete", outcome.Message); + Assert.Equal([false], broadcaster.FinishedResults); + } + + [Fact] + public async Task ImportTickets_without_a_ticket_system_is_plain_fill_my_day() + { + var claude = new FakeClaudeProcess(exitCode: 0, result: "ok"); + var runner = NewRunner(claude, new RecordingPrimeBroadcaster(), ticketsConfigured: false); + + var outcome = await runner.FireAsync(Schedule(PrimeActionKind.ImportTickets), CancellationToken.None); + + Assert.True(outcome.Success); + var only = Assert.Single(claude.AllArguments); + Assert.Contains(DailyPrepPrompt.SetMyDayTool, only); + } + + [Fact] + public async Task ImportTickets_writes_both_runs_into_one_prep_log() + { + var path = DailyPrepPrompt.LogPath(); + if (File.Exists(path)) File.Delete(path); + var claude = new FakeClaudeProcess(emitLines: ["step-line"], exitCode: 0, result: "ok"); + var runner = NewRunner(claude, new RecordingPrimeBroadcaster(), ticketsConfigured: true); + + await runner.FireAsync(Schedule(PrimeActionKind.ImportTickets), CancellationToken.None); + + var lines = (await File.ReadAllLinesAsync(path)).Count(l => l.Contains("step-line")); + Assert.Equal(2, lines); + } + [Fact] public async Task Gate_is_shared_across_kinds() { diff --git a/tests/ClaudeDo.Worker.Tests/Prime/TicketImportPromptTests.cs b/tests/ClaudeDo.Worker.Tests/Prime/TicketImportPromptTests.cs new file mode 100644 index 00000000..20e6ceb4 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Prime/TicketImportPromptTests.cs @@ -0,0 +1,27 @@ +using ClaudeDo.Worker.Prime; + +namespace ClaudeDo.Worker.Tests.Prime; + +public class TicketImportPromptTests +{ + [Fact] + public void Args_allow_only_the_two_ticket_tools() + { + var args = TicketImportPrompt.BuildArgs(); + var i = args.ToList().IndexOf("--allowedTools"); + + Assert.Equal([TicketImportPrompt.CandidatesTool, TicketImportPrompt.ImportTool], args.Skip(i + 1)); + Assert.Contains("acceptEdits", args); + } + + [Fact] + public void Override_is_appended_never_substituted() + { + var baseline = TicketImportPrompt.BuildPrompt(); + var prompt = TicketImportPrompt.BuildPrompt("Lager items go to Hub."); + + Assert.StartsWith(baseline, prompt); + Assert.EndsWith("Lager items go to Hub.", prompt); + Assert.Equal(baseline, TicketImportPrompt.BuildPrompt(" ")); + } +}