diff --git a/ClaudeDo.slnx b/ClaudeDo.slnx
index efcd91e..a8d78a5 100644
--- a/ClaudeDo.slnx
+++ b/ClaudeDo.slnx
@@ -7,6 +7,7 @@
+
diff --git a/src/ClaudeDo.App/ClaudeDo.App.csproj b/src/ClaudeDo.App/ClaudeDo.App.csproj
index 2b2c2b4..dd9c879 100644
--- a/src/ClaudeDo.App/ClaudeDo.App.csproj
+++ b/src/ClaudeDo.App/ClaudeDo.App.csproj
@@ -24,10 +24,12 @@
+
+
diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs
index 0c68eb0..f3bda26 100644
--- a/src/ClaudeDo.App/Program.cs
+++ b/src/ClaudeDo.App/Program.cs
@@ -13,6 +13,8 @@ using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Serilog;
using System;
using System.Globalization;
using System.IO;
@@ -77,6 +79,12 @@ sealed class Program
var sc = new ServiceCollection();
+ var logRoot = Path.Combine(Path.GetDirectoryName(dbPath)!, "logs");
+ var serilogLogger = ClaudeDo.Logging.LoggingSetup
+ .Configure(new LoggerConfiguration(), "app", logRoot)
+ .CreateLogger();
+ sc.AddLogging(b => b.AddSerilog(serilogLogger, dispose: true));
+
// Infrastructure
sc.AddSingleton(settings);
var localesDir = Path.Combine(AppContext.BaseDirectory, "locales");
@@ -98,7 +106,9 @@ sealed class Program
// Services
sc.AddSingleton();
- sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService().SignalRUrl));
+ sc.AddSingleton(sp => new WorkerClient(
+ sp.GetRequiredService().SignalRUrl,
+ sp.GetRequiredService>()));
sc.AddSingleton(sp => sp.GetRequiredService());
// Release check + installer update
diff --git a/src/ClaudeDo.Logging/BuildConfig.cs b/src/ClaudeDo.Logging/BuildConfig.cs
new file mode 100644
index 0000000..2e13542
--- /dev/null
+++ b/src/ClaudeDo.Logging/BuildConfig.cs
@@ -0,0 +1,14 @@
+using System.Diagnostics;
+using System.Reflection;
+
+namespace ClaudeDo.Logging;
+
+/// Runtime build-configuration detection — the replacement for #if DEBUG.
+/// Debug builds compile with the JIT optimizer disabled; Release builds enable it.
+public static class BuildConfig
+{
+ public static bool IsDebug { get; } =
+ Assembly.GetEntryAssembly()
+ ?.GetCustomAttribute()
+ ?.IsJITOptimizerDisabled ?? false;
+}
diff --git a/src/ClaudeDo.Logging/ClaudeDo.Logging.csproj b/src/ClaudeDo.Logging/ClaudeDo.Logging.csproj
new file mode 100644
index 0000000..985392d
--- /dev/null
+++ b/src/ClaudeDo.Logging/ClaudeDo.Logging.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs b/src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs
new file mode 100644
index 0000000..cea4b60
--- /dev/null
+++ b/src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs
@@ -0,0 +1,15 @@
+using Serilog.Core;
+using Serilog.Events;
+
+namespace ClaudeDo.Logging;
+
+/// Ensures every log event carries a TaskId property (defaulting to "-")
+/// so the output template's [{TaskId}] column never renders the raw token.
+public sealed class DefaultTaskIdEnricher : ILogEventEnricher
+{
+ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
+ {
+ if (!logEvent.Properties.ContainsKey("TaskId"))
+ logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TaskId", "-"));
+ }
+}
diff --git a/src/ClaudeDo.Logging/LoggingSetup.cs b/src/ClaudeDo.Logging/LoggingSetup.cs
new file mode 100644
index 0000000..c93ea40
--- /dev/null
+++ b/src/ClaudeDo.Logging/LoggingSetup.cs
@@ -0,0 +1,44 @@
+using Serilog;
+using Serilog.Events;
+
+namespace ClaudeDo.Logging;
+
+public static class LoggingSetup
+{
+ private const string OutputTemplate =
+ "[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Process}/{SourceContext} [{TaskId}] {Message:lj}{NewLine}{Exception}";
+
+ public static LoggerConfiguration Configure(LoggerConfiguration cfg, string processTag, string logRoot)
+ {
+ Directory.CreateDirectory(logRoot);
+ var logFile = Path.Combine(logRoot, "claudedo-.log");
+
+ cfg.Enrich.FromLogContext()
+ .Enrich.WithProperty("Process", processTag)
+ .Enrich.With(new DefaultTaskIdEnricher());
+
+ if (BuildConfig.IsDebug)
+ {
+ cfg.MinimumLevel.Debug()
+ .WriteTo.Console(outputTemplate: OutputTemplate)
+ .WriteTo.File(
+ logFile,
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: 2,
+ shared: true,
+ outputTemplate: OutputTemplate);
+ }
+ else
+ {
+ cfg.MinimumLevel.Warning()
+ .WriteTo.File(
+ logFile,
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: 2,
+ shared: true,
+ outputTemplate: OutputTemplate);
+ }
+
+ return cfg;
+ }
+}
diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
index 47a97fb..ac343b2 100644
--- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
+++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
@@ -11,7 +11,9 @@
+
+
diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs
index 92e5813..471e03e 100644
--- a/src/ClaudeDo.Ui/Services/WorkerClient.cs
+++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs
@@ -6,6 +6,8 @@ using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Serilog.Context;
namespace ClaudeDo.Ui.Services;
@@ -30,6 +32,7 @@ sealed class IndefiniteRetryPolicy : IRetryPolicy
public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerClient
{
private readonly HubConnection _hub;
+ private readonly ILogger _logger;
private CancellationTokenSource? _startCts;
private Task _retryLoopTask = Task.CompletedTask;
private readonly object _startLock = new();
@@ -65,8 +68,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public string? LastMergeAllTarget { get; private set; }
- public WorkerClient(string signalRUrl)
+ public WorkerClient(string signalRUrl, ILogger logger)
{
+ _logger = logger;
_hub = new HubConnectionBuilder()
.WithUrl(signalRUrl)
.WithAutomaticReconnect(new IndefiniteRetryPolicy())
@@ -240,20 +244,24 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
catch { return default; }
}
- public async Task RunNowAsync(string taskId)
+ /// Invoke a task-targeted hub method under a TaskId log scope, emitting a debug trace line.
+ private async Task InvokeForTaskAsync(string taskId, string method, params object?[] args)
{
- await _hub.InvokeAsync("RunNow", taskId);
+ using (LogContext.PushProperty("TaskId", taskId))
+ {
+ _logger.LogDebug("UI invoking {Method} for task {TaskId}", method, taskId);
+ await _hub.InvokeCoreAsync(method, args);
+ }
}
- public async Task ContinueTaskAsync(string taskId, string followUpPrompt)
- {
- await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt);
- }
+ public Task RunNowAsync(string taskId)
+ => InvokeForTaskAsync(taskId, "RunNow", taskId);
- public async Task ResetTaskAsync(string taskId)
- {
- await _hub.InvokeAsync("ResetTask", taskId);
- }
+ public Task ContinueTaskAsync(string taskId, string followUpPrompt)
+ => InvokeForTaskAsync(taskId, "ContinueTask", taskId, followUpPrompt);
+
+ public Task ResetTaskAsync(string taskId)
+ => InvokeForTaskAsync(taskId, "ResetTask", taskId);
public async Task MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
{
@@ -264,10 +272,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task GetMergeTargetsAsync(string taskId)
=> TryInvokeAsync("GetMergeTargets", taskId);
- public async Task CancelTaskAsync(string taskId)
- {
- await _hub.InvokeAsync("CancelTask", taskId);
- }
+ public Task CancelTaskAsync(string taskId)
+ => InvokeForTaskAsync(taskId, "CancelTask", taskId);
public async Task WakeQueueAsync()
{
@@ -386,25 +392,17 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
}
- public async Task ApproveReviewAsync(string taskId)
- {
- await _hub.InvokeAsync("ApproveReview", taskId);
- }
+ public Task ApproveReviewAsync(string taskId)
+ => InvokeForTaskAsync(taskId, "ApproveReview", taskId);
- public async Task RejectReviewToQueueAsync(string taskId, string feedback)
- {
- await _hub.InvokeAsync("RejectReviewToQueue", taskId, feedback);
- }
+ public Task RejectReviewToQueueAsync(string taskId, string feedback)
+ => InvokeForTaskAsync(taskId, "RejectReviewToQueue", taskId, feedback);
- public async Task RejectReviewToIdleAsync(string taskId)
- {
- await _hub.InvokeAsync("RejectReviewToIdle", taskId);
- }
+ public Task RejectReviewToIdleAsync(string taskId)
+ => InvokeForTaskAsync(taskId, "RejectReviewToIdle", taskId);
- public async Task CancelReviewAsync(string taskId)
- {
- await _hub.InvokeAsync("CancelReview", taskId);
- }
+ public Task CancelReviewAsync(string taskId)
+ => InvokeForTaskAsync(taskId, "CancelReview", taskId);
public Task CleanupFinishedWorktreesAsync(string? listId = null)
=> TryInvokeAsync("CleanupFinishedWorktrees", listId);
diff --git a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
index 45c64d3..bab0594 100644
--- a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
+++ b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
@@ -2,6 +2,7 @@
+
diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs
index 3f06a19..71ebc32 100644
--- a/src/ClaudeDo.Worker/Program.cs
+++ b/src/ClaudeDo.Worker/Program.cs
@@ -31,13 +31,8 @@ var builder = WebApplication.CreateBuilder(args);
var logRoot = cfg.LogRoot;
Directory.CreateDirectory(logRoot);
-builder.Host.UseSerilog((ctx, lc) => lc
- .MinimumLevel.Information()
- .WriteTo.File(
- System.IO.Path.Combine(logRoot, "worker-.log"),
- rollingInterval: RollingInterval.Day,
- retainedFileCountLimit: 7,
- shared: true));
+builder.Host.UseSerilog((ctx, lc) =>
+ ClaudeDo.Logging.LoggingSetup.Configure(lc, "worker", logRoot));
builder.Services.AddDbContextFactory(opt =>
opt.UseSqlite($"Data Source={cfg.DbPath}"));
diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs
index 53fda84..f18bb03 100644
--- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs
+++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using ClaudeDo.Data;
+using Serilog.Context;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
@@ -46,6 +47,7 @@ public sealed class TaskRunner
public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct)
{
+ using var _taskScope = LogContext.PushProperty("TaskId", task.Id);
string? mcpToken = null;
string? mcpConfigPath = null;
try
@@ -170,6 +172,7 @@ public sealed class TaskRunner
public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct)
{
+ using var _taskScope = LogContext.PushProperty("TaskId", taskId);
TaskEntity task;
TaskRunEntity lastRun;
ListEntity list;
diff --git a/tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj b/tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
index bfa4f50..c2fb766 100644
--- a/tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
+++ b/tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
@@ -25,6 +25,7 @@
+
diff --git a/tests/ClaudeDo.Worker.Tests/Logging/BuildConfigTests.cs b/tests/ClaudeDo.Worker.Tests/Logging/BuildConfigTests.cs
new file mode 100644
index 0000000..76d9293
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Logging/BuildConfigTests.cs
@@ -0,0 +1,19 @@
+using System.Diagnostics;
+using System.Reflection;
+using ClaudeDo.Logging;
+
+namespace ClaudeDo.Worker.Tests.Logging;
+
+public sealed class BuildConfigTests
+{
+ [Fact]
+ public void IsDebug_MatchesEntryAssemblyDebuggableAttribute()
+ {
+ var entry = Assembly.GetEntryAssembly();
+ var expected = entry?
+ .GetCustomAttribute()
+ ?.IsJITOptimizerDisabled ?? false;
+
+ Assert.Equal(expected, BuildConfig.IsDebug);
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Logging/DefaultTaskIdEnricherTests.cs b/tests/ClaudeDo.Worker.Tests/Logging/DefaultTaskIdEnricherTests.cs
new file mode 100644
index 0000000..d360f1f
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Logging/DefaultTaskIdEnricherTests.cs
@@ -0,0 +1,49 @@
+using ClaudeDo.Logging;
+using Serilog;
+using Serilog.Context;
+using Serilog.Core;
+using Serilog.Events;
+
+namespace ClaudeDo.Worker.Tests.Logging;
+
+public sealed class DefaultTaskIdEnricherTests
+{
+ private sealed class CollectingSink : ILogEventSink
+ {
+ public List Events { get; } = new();
+ public void Emit(LogEvent logEvent) => Events.Add(logEvent);
+ }
+
+ [Fact]
+ public void AddsDash_WhenNoTaskIdInScope()
+ {
+ var sink = new CollectingSink();
+ using var logger = new LoggerConfiguration()
+ .Enrich.FromLogContext()
+ .Enrich.With(new DefaultTaskIdEnricher())
+ .WriteTo.Sink(sink)
+ .CreateLogger();
+
+ logger.Information("hello");
+
+ var prop = Assert.Single(sink.Events).Properties["TaskId"];
+ Assert.Equal("\"-\"", prop.ToString());
+ }
+
+ [Fact]
+ public void KeepsPushedTaskId_WhenInScope()
+ {
+ var sink = new CollectingSink();
+ using var logger = new LoggerConfiguration()
+ .Enrich.FromLogContext()
+ .Enrich.With(new DefaultTaskIdEnricher())
+ .WriteTo.Sink(sink)
+ .CreateLogger();
+
+ using (LogContext.PushProperty("TaskId", "task-42"))
+ logger.Information("hello");
+
+ var prop = Assert.Single(sink.Events).Properties["TaskId"];
+ Assert.Equal("\"task-42\"", prop.ToString());
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Logging/LoggingSetupTests.cs b/tests/ClaudeDo.Worker.Tests/Logging/LoggingSetupTests.cs
new file mode 100644
index 0000000..4706c9c
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Logging/LoggingSetupTests.cs
@@ -0,0 +1,30 @@
+using ClaudeDo.Logging;
+using Serilog;
+
+namespace ClaudeDo.Worker.Tests.Logging;
+
+public sealed class LoggingSetupTests
+{
+ [Fact]
+ public void Configure_WritesSharedLogFile()
+ {
+ var logRoot = Path.Combine(Path.GetTempPath(), "claudedo-logtest-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(logRoot);
+ try
+ {
+ var logger = LoggingSetup.Configure(new LoggerConfiguration(), "test", logRoot).CreateLogger();
+ logger.Warning("marker-{Marker}", "xyz");
+ logger.Dispose(); // flush + release the file handle
+
+ var files = Directory.GetFiles(logRoot, "claudedo-*.log");
+ var file = Assert.Single(files);
+ var contents = File.ReadAllText(file);
+ Assert.Contains("marker-", contents);
+ Assert.Contains("test/", contents); // {Process} tag in the template
+ }
+ finally
+ {
+ try { Directory.Delete(logRoot, recursive: true); } catch { /* best effort */ }
+ }
+ }
+}