WW-sync — Live sync progress + hang fix¶
Two "sync" flows hang forever with a blind "Syncing…" spinner. Root cause: long per-item
loops with no timeout and no per-item try/catch — one hung HTTP/LLM call never returns,
the controller never responds, the frontend spinner never clears. Live log proof: two
POST /api/jobs/resync calls logged "Request starting" + "Scraped 60 job postings" then
never "Request finished".
Deliver: (1) both loops hardened so they always return, (2) a live N/60 progress counter,
(3) the counter drives an outline that traces the perimeter of the "Syncing…" box and
closes the loop at 100%.
BACKEND (agent owns src/** only — do NOT touch frontend/**)¶
New: src/WorkWingman.Core/Models/SyncProgress.cs¶
namespace WorkWingman.Core.Models;
public enum SyncStatus { Running, Completed, PartialSuccess, Failed }
public class SyncProgress
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Kind { get; set; } = ""; // "linkedin-resync" | "drive-backup"
public string Label { get; set; } = ""; // human header, e.g. "Re-syncing LinkedIn"
public string Phase { get; set; } = ""; // "Scraping" | "Analyzing fit" | "Enriching" | "Uploading"
public int Current { get; set; }
public int Total { get; set; }
public int Failed { get; set; }
public SyncStatus Status { get; set; } = SyncStatus.Running;
public string Message { get; set; } = "";
public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? FinishedAt { get; set; }
}
New: src/WorkWingman.Infrastructure/Services/SyncProgressTracker.cs¶
In-memory, mirrors the existing ApplicationRunService.ActiveRuns pattern. Thread-safe
(ConcurrentDictionary + lock (progress) on mutation). Register as singleton.
public interface ISyncProgressTracker
{
SyncProgress Begin(string kind, string label, string phase, int total);
void SetTotal(string id, int total);
void SetPhase(string id, string phase);
void Advance(string id, int by = 1);
void RecordFailure(string id);
void Complete(string id, string message, SyncStatus status = SyncStatus.Completed);
SyncProgress? GetActive();
}
GetActive() returns the running progress, OR a just-finished one within a 20s "linger"
window (so the frontend gets one poll showing Completed/PartialSuccess before it vanishes).
Lazily drop finished entries older than the linger window on each call.
- Complete sets Status, Message, FinishedAt. Idempotent.
New: src/WorkWingman.Api/Controllers/SyncController.cs¶
[ApiController]
[Route("api/sync")]
public class SyncController(ISyncProgressTracker tracker) : ControllerBase
{
[HttpGet("progress")]
public ActionResult<SyncProgress> Progress()
=> tracker.GetActive() is { } p ? Ok(p) : NoContent();
}
Edit: JobQueueService.ResyncFromLinkedInAsync (src/WorkWingman.Infrastructure/Services/JobQueueService.cs:74)¶
Inject ISyncProgressTracker tracker via primary ctor. Then:
1. var p = tracker.Begin("linkedin-resync", "Re-syncing LinkedIn", "Scraping", 0);
2. Wrap the entire body in try / catch / finally. The finally must call
tracker.Complete(...) exactly once — this is the guarantee the request always resolves.
3. Overall timeout: build a linked token —
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromMinutes(8)); var lct = cts.Token;
and pass lct to the scrape + enrichment calls.
4. After scrape+add, tracker.SetTotal(p.Id, added.Count).
5. Parallel narrative loop (:189): tracker.SetPhase(p.Id, "Analyzing fit") before it.
6. Sequential enrichment loop (:224): tracker.SetPhase(p.Id, "Enriching"). Wrap each
job's body in try { … } catch (Exception ex) when (ex is not OperationCanceledException)
{ logger.LogWarning(ex, "enrich failed for {Job}", job.Id); tracker.RecordFailure(p.Id); }
then tracker.Advance(p.Id) in a finally per job. One bad job must not abort the sync.
7. finally: Failed>0 ? PartialSuccess : Completed; message e.g.
"Pulled {added.Count} jobs — {added.Count-Failed} enriched, {Failed} skipped."
On OperationCanceledException from the timeout: Complete(Failed, "Timed out after 8 min…").
Return value stays int (added.Count).
Edit: DataPortabilityService.SyncToDriveAsync (src/WorkWingman.Infrastructure/Services/DataPortabilityService.cs:183)¶
Inject tracker. Begin("drive-backup", $"Syncing {domain.DisplayName} to Drive", "Uploading", rows.Count).
Same shape: linked-token timeout (5 min), per-row try/catch → RecordFailure+continue (do
NOT abort the whole upload on one row), Advance per row in finally, Complete in an outer
finally. Return DriveSyncResult with Synced: Failed==0, message includes failed count if any.
DI: src/WorkWingman.Api/Program.cs¶
builder.Services.AddSingleton<ISyncProgressTracker, SyncProgressTracker>();
Verify: dotnet build the API project green. Add/adjust unit tests for the tracker¶
(Begin/Advance/Complete/GetActive linger) + a JobQueueService test proving one throwing job still completes the run (Failed==1, Status==PartialSuccess). Match existing test style (Bogus).
FRONTEND (agent owns frontend/** only — do NOT touch src/**)¶
New standalone component: frontend/src/app/shared/progress-ring/progress-ring.{ts,html,scss}¶
Wraps arbitrary content; draws an animated outline around the content box's perimeter.
- Inputs: percent: number (0–100), active: boolean.
- Template:
<div class="ring-host" [class.active]="active()">
<ng-content />
<svg class="ring-outline" preserveAspectRatio="none" aria-hidden="true">
<rect class="track" x="1" y="1" pathLength="100" vector-effect="non-scaling-stroke" />
<rect class="fill" x="1" y="1" pathLength="100" vector-effect="non-scaling-stroke"
[style.stroke-dashoffset]="100 - clamped()" />
</svg>
</div>
.ring-host{position:relative}. .ring-outline{position:absolute; inset:0; width:100%;
height:100%; pointer-events:none}. Both rects width:calc(100% - 2px); height:calc(100% - 2px);
rx: <match the box radius, use the WW radius token>; fill:none. .track faint
(var(--border) low alpha). .fill stroke: var(--accent); stroke-dasharray:100;
stroke-linecap:round; transition: stroke-dashoffset .4s ease. Start the trace at
top-center via transform: rotate(...) or by leaving default (top-left is acceptable — pick
top-center if clean). Respect prefers-reduced-motion (drop the transition).
- clamped() = Math.max(0, Math.min(100, percent())). Theme-aware (accent token works
light+dark).
Models: frontend/src/app/core/models.ts¶
Add SyncProgress interface mirroring the backend record (kind, label, phase, current, total,
failed, status, message).
API: frontend/src/app/core/api.service.ts¶
getSyncProgress(): Observable<SyncProgress | null> → GET /api/sync/progress, map 204→null
(catchError(() => of(null))).
Wire frontend/src/app/features/queue/ (the Resync box)¶
- Add signals
syncCurrent,syncTotal,syncPhase. - In
resync(): on start, kick asetInterval(~1000ms)pollinggetSyncProgress()→ set the signals. Clear the interval in the subscribe's complete/error (andngOnDestroy). - Template: wrap the "Re-sync LinkedIn / Syncing…" button-box in
<app-progress-ring [active]="syncing()" [percent]="syncTotal() ? syncCurrent()/syncTotal()*100 : 0">. When syncing, show{{ syncCurrent() }}/{{ syncTotal() }}+syncPhase()next to the label.
Wire frontend/src/app/features/data-backup/ (the per-domain Sync box)¶
- Same: while a domain is
'syncing', pollgetSyncProgress(); wrap that domain's status box in the progress-ring; showN/total. (Only one sync runs at a time — match onkind === 'drive-backup'.)
Verify: frontend build/tsc green + existing Vitest suite passes. Add a small spec for¶
progress-ring rendering the fill offset for a given percent.
Gate¶
After both sides land + build green: /council-code-review, then FF-push to master, rebuild
installer, redeploy to fleet. Do NOT push from the builder agents — leave that to the reviewer.