Files
TaskTracker/WindowWatcher/Worker.cs
AJ Isaacs 5db92d5127 fix(watcher): fix TickCount wrap and resume retry on API failure
- Use 32-bit Environment.TickCount with unchecked uint arithmetic so
  GetIdleTime stays correct after the ~49.7 day uint wrap boundary
- Only clear _pausedTaskId after successful resume, not before the API call
- Add retry path: if resume failed, retry on next poll cycle while user
  is still active

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 00:18:22 -05:00

216 lines
7.5 KiB
C#

using System.Diagnostics;
using System.Net.Http.Json;
using System.Text;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace WindowWatcher;
public class Worker(
IHttpClientFactory httpClientFactory,
IOptions<WindowWatcherOptions> options,
ILogger<Worker> logger) : BackgroundService
{
private string _lastAppName = string.Empty;
private string _lastWindowTitle = string.Empty;
private DateTime _lastChangeTime = DateTime.MinValue;
private bool _isIdle;
private int? _pausedTaskId;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var config = options.Value;
logger.LogInformation(
"WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms, idle timeout {IdleTimeout}ms",
config.PollIntervalMs, config.DebounceMs, config.IdleTimeoutMs);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var hwnd = NativeMethods.GetForegroundWindow();
if (hwnd == IntPtr.Zero)
{
await Task.Delay(config.PollIntervalMs, stoppingToken);
continue;
}
// Get window title
var sb = new StringBuilder(512);
NativeMethods.GetWindowText(hwnd, sb, sb.Capacity);
var windowTitle = sb.ToString();
// Get process name
NativeMethods.GetWindowThreadProcessId(hwnd, out var pid);
string appName;
try
{
var process = Process.GetProcessById((int)pid);
appName = process.ProcessName;
}
catch
{
appName = "Unknown";
}
// Check if changed
if (appName != _lastAppName || windowTitle != _lastWindowTitle)
{
var now = DateTime.UtcNow;
// Debounce: only report if last change was more than DebounceMs ago
if ((now - _lastChangeTime).TotalMilliseconds >= config.DebounceMs
&& !string.IsNullOrWhiteSpace(windowTitle))
{
await ReportContextEvent(appName, windowTitle, stoppingToken);
}
_lastAppName = appName;
_lastWindowTitle = windowTitle;
_lastChangeTime = now;
}
// Idle detection
var idleTime = NativeMethods.GetIdleTime();
if (!_isIdle && _pausedTaskId is not null && idleTime.TotalMilliseconds < config.IdleTimeoutMs)
{
// Retry resume from a previous failed attempt
await ResumeIdlePausedTaskAsync(ct: stoppingToken);
}
else if (!_isIdle && idleTime.TotalMilliseconds >= config.IdleTimeoutMs)
{
_isIdle = true;
logger.LogInformation("User idle for {IdleTime}, pausing active task", idleTime);
await PauseActiveTaskAsync(ct: stoppingToken);
}
else if (_isIdle && idleTime.TotalMilliseconds < config.IdleTimeoutMs)
{
_isIdle = false;
logger.LogInformation("User returned from idle");
await ResumeIdlePausedTaskAsync(ct: stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Error polling foreground window");
}
await Task.Delay(config.PollIntervalMs, stoppingToken);
}
}
private async Task ReportContextEvent(string appName, string windowTitle, CancellationToken ct)
{
try
{
var client = httpClientFactory.CreateClient("TaskTrackerApi");
var payload = new
{
source = "WindowWatcher",
appName,
windowTitle
};
var response = await client.PostAsJsonAsync("/api/context", payload, ct);
if (response.IsSuccessStatusCode)
{
logger.LogDebug("Reported: {App} - {Title}", appName, windowTitle);
}
else
{
logger.LogWarning("API returned {StatusCode} for context event", response.StatusCode);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to report context event to API");
}
}
private async Task PauseActiveTaskAsync(CancellationToken ct)
{
try
{
var client = httpClientFactory.CreateClient("TaskTrackerApi");
// Get the active task
var response = await client.GetFromJsonAsync<ApiResponse<ActiveTaskDto>>(
"/api/tasks/active", ct);
if (response?.Data is null)
{
logger.LogDebug("No active task to pause");
return;
}
_pausedTaskId = response.Data.Id;
// Pause it
var pauseResponse = await client.PutAsJsonAsync(
$"/api/tasks/{_pausedTaskId}/pause",
new { note = "Auto-paused: idle timeout" }, ct);
if (pauseResponse.IsSuccessStatusCode)
logger.LogInformation("Auto-paused task {TaskId}", _pausedTaskId);
else
logger.LogWarning("Failed to pause task {TaskId}: {Status}", _pausedTaskId, pauseResponse.StatusCode);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to pause active task on idle");
}
}
private async Task ResumeIdlePausedTaskAsync(CancellationToken ct)
{
if (_pausedTaskId is null)
return;
var taskId = _pausedTaskId.Value;
try
{
var client = httpClientFactory.CreateClient("TaskTrackerApi");
// Check the task is still paused (user may have manually switched tasks)
var response = await client.GetFromJsonAsync<ApiResponse<ActiveTaskDto>>(
$"/api/tasks/{taskId}", ct);
if (response?.Data is null || response.Data.Status != "Paused")
{
logger.LogDebug("Task {TaskId} is no longer paused, skipping auto-resume", taskId);
_pausedTaskId = null;
return;
}
// Resume it
var resumeResponse = await client.PutAsJsonAsync(
$"/api/tasks/{taskId}/resume",
new { note = "Auto-resumed: user returned" }, ct);
if (resumeResponse.IsSuccessStatusCode)
{
logger.LogInformation("Auto-resumed task {TaskId}", taskId);
_pausedTaskId = null;
}
else
{
logger.LogWarning("Failed to resume task {TaskId}: {Status}, will retry", taskId, resumeResponse.StatusCode);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to resume task {TaskId} after idle, will retry", taskId);
}
}
}
internal record ActiveTaskDto(int Id, string Status);
internal record ApiResponse<T>(bool Success, T? Data, string? Error);