feat(prime): ImportTickets runs the ticket import before filling MyDay

This commit is contained in:
mika kuns
2026-10-01 08:39:38 +02:00
parent 57c30c4a18
commit 915f2c8352
4 changed files with 237 additions and 43 deletions
+89 -38
View File
@@ -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<PrimeRunner> _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<ClaudeDoDbContext> dbFactory,
IPrimeClock clock,
ILogger<PrimeRunner> logger,
IPrimeBroadcaster broadcaster)
IPrimeBroadcaster broadcaster,
TicketSystemConfig tickets)
{
_claude = claude;
_dbFactory = dbFactory;
_clock = clock;
_logger = logger;
_broadcaster = broadcaster;
_tickets = tickets;
}
public async Task<PrimeRunOutcome> 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<PrimeRunOutcome> RunFillMyDayAsync(PrimeScheduleDto schedule, CancellationToken ct)
/// <summary>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.</summary>
private Task<PrimeRunOutcome> 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}");
});
}
/// <summary>Truncates the prep log once and brackets <paramref name="body"/> with PrepStarted/PrepFinished.</summary>
private async Task<PrimeRunOutcome> RunPrepAsync(string label, Func<LogWriter, Task<PrimeRunOutcome>> 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
}
}
/// <summary>The MyDay selection run, writing into an already-open prep log.</summary>
private async Task<PrimeRunOutcome> 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);
}
/// <summary>One claude run streaming into the prep log and the UI; a timeout is an outcome, not an exception.</summary>
private async Task<PrimeRunOutcome> RunLoggedAsync(
IReadOnlyList<string> 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<PrimeRunOutcome> RunPingAsync(CancellationToken ct) =>
RunQuietAsync(PrimePrompts.BuildPingArgs(), PrimePrompts.PingPrompt, "Ping complete", "Ping", ct);
@@ -0,0 +1,28 @@
using ClaudeDo.Data;
namespace ClaudeDo.Worker.Prime;
/// <summary>Prompt and CLI args for the ticket-import step of an ImportTickets Prime run.</summary>
public static class TicketImportPrompt
{
public const string CandidatesTool = "mcp__claudedo__get_ticket_import_candidates";
public const string ImportTool = "mcp__claudedo__import_ticket";
/// <summary>One import call per item, so the budget scales with the backlog — daily prep's 30 would cap it at ~25 items.</summary>
public const int MaxTurns = 60;
public static IReadOnlyList<string> BuildArgs() =>
[
"-p", "--output-format", "stream-json", "--verbose",
"--permission-mode", "acceptEdits",
"--max-turns", MaxTurns.ToString(),
"--allowedTools", CandidatesTool, ImportTool,
];
/// <summary>A schedule's override is appended, never substituted — same rule as <see cref="DailyPrepPrompt"/>.</summary>
public static string BuildPrompt(string? promptOverride = null)
{
var prompt = PromptFiles.Render(PromptKind.TicketImport, new Dictionary<string, string>());
return string.IsNullOrWhiteSpace(promptOverride) ? prompt : $"{prompt}\n\n{promptOverride}";
}
}