Files
OpenNest/OpenNest.Server.Worker/NestJobReporter.cs
T
ajandClaude Opus 5.5 b0cdbb3e81 feat(server): stream server nest progress and notify when a job ends
A server Auto Nest showed only "Nesting on the nest server" for however long
the job took, and Stop could take a whole 20-second heartbeat to reach the
worker. Now the desktop shows the same Best plan so far strip a local run
does, and a job that ends while OpenNest is in the background says so.

- Worker: RecordingNestingEngine taps each solve's progress. NestJobReporter
  stores only the latest stage and plan, at most once every two seconds, so a
  chatty engine cannot flood SQLite. Each round also reads the cancel flag,
  so Stop reaches the worker within one interval (about 2 s in the Docker
  smoke test).
- Server: schema v16 adds per-job revision, progress, plan and planRevision
  columns, which the upgrade adds only where missing. PUT .../progress and
  .../plan renew the lease like a heartbeat. GET .../events is Server-Sent
  Events, woken by an in-process change signal: plan and job events with
  revision ids, keep-alives, Last-Event-ID resume, and the final state always
  sent before the stream ends. GET .../plan serves the polling fallback.
  Progress goes over HTTP because the worker never opens the database
  (another host, NFS).
- Desktop: NestJobWatch follows the stream. When the stream drops it polls
  once and reconnects, and it polls throughout when the server has no stream.
  RemoteNestPlanPreview draws worker plans from the client's own request,
  including cutout-trial frame proxies. ServerNestNotice shows a tray balloon
  (a toast on Windows 10/11) when the job ends while OpenNest is not in
  front. A real toast would need an AUMID shortcut that Velopack does not
  promise. Clicking the balloon brings the review forward; nothing is kept
  without Keep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:18:10 -05:00

94 lines
3.7 KiB
C#

using OpenNest.Data;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Remote;
namespace OpenNest.Server.Worker;
/// <summary>
/// Collects a running job's engine progress and stores it on the nest server at most once per
/// interval: only the latest stage and the latest plan are sent, so a chatty engine costs one
/// small write and one plan write per interval at most. Each round, a report (or, with nothing
/// new to send, a read of the job) also tells the worker promptly that someone asked to stop.
/// </summary>
public sealed class NestJobReporter : IProgress<NestJobProgress>
{
private readonly object _sync = new();
private RemoteNestProgress? _stage;
private RemoteNestPlan? _plan;
public void Report(NestJobProgress value)
{
if (value is null)
return;
// Copied here, on the engine's thread, so later changes to its objects cannot leak in;
// serialized later, off that thread.
if (value.Stage == NestJobStage.PlanImproved)
{
if (value.Plan is null)
return;
var plan = RemoteNestPlan.From(value.Plan);
lock (_sync)
_plan = plan;
return;
}
var stage = RemoteNestProgress.From(value);
lock (_sync)
_stage = stage;
}
/// <summary>
/// Sends reports every <paramref name="interval"/> until <paramref name="token"/> is
/// cancelled. Returns why the job must stop (a cancel request, or the worker lost it),
/// or <see cref="NestJobWorker.StopReason.None"/> when cancelled first.
/// </summary>
public async Task<NestJobWorker.StopReason> RunAsync(RemoteNestJobs jobs, Guid id, string workerId, TimeSpan interval,
ILogger logger, CancellationToken token)
{
while (true)
{
try { await Task.Delay(interval, token); }
catch (OperationCanceledException) { return NestJobWorker.StopReason.None; }
RemoteNestProgress? stage;
RemoteNestPlan? plan;
lock (_sync)
{
(stage, plan) = (_stage, _plan);
(_stage, _plan) = (null, null);
}
try
{
var beats = new List<NestJobHeartbeat?>();
if (plan is not null)
beats.Add(await jobs.ReportPlanAsync(id, workerId, plan.ToJson(), token));
if (stage is not null)
beats.Add(await jobs.ReportProgressAsync(id, workerId, stage.ToJson(), token));
if (beats.Count == 0)
{
// Nothing new: read the job instead, which costs the server no write.
var job = await jobs.GetAsync(id, token);
beats.Add(job is { State: NestJobState.Running } ? new NestJobHeartbeat { CancelRequested = job.CancelRequested } : null);
}
if (beats.Any(b => b is null))
return NestJobWorker.StopReason.LeaseLost;
if (beats.Any(b => b!.CancelRequested))
return NestJobWorker.StopReason.CancelRequested;
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
return NestJobWorker.StopReason.None;
}
catch (Exception ex) when (ex is IOException or HttpRequestException or TaskCanceledException)
{
// Keep solving; the next round sends whatever is latest by then.
lock (_sync)
{
_stage ??= stage;
_plan ??= plan;
}
logger.LogWarning("Progress for nest job {Id} not stored: {Message}", id, ex.Message);
}
}
}
}