Skip to content

Below is a deep review of background services, worker loops, and long-running task execution in .NET from the perspective of runtime behavior and engineering design, not just API usage.


PART 1 — CORE CONCEPTS RECAP

Foreground vs background work

At a practical level, “background work” means work that is not executed inline on the current control flow that initiated the operation, especially not on a latency-sensitive thread like a UI thread or request-handling path.

In .NET there are two different ideas people often mix together:

1. Background as an architectural idea

This means work happens “off to the side”:

  • polling a device
  • processing a queue
  • flushing logs
  • retrying failed operations
  • streaming incoming events

This is about responsibility and timing.

2. Background as a thread property

A .NET Thread has an IsBackground flag.

  • Foreground thread: keeps the process alive
  • Background thread: does not keep the process alive

If all foreground threads exit, the process ends, and background threads are just terminated by process shutdown.

This is important because many engineers think “background task” means “special reliable daemon.” It does not. A Task is not automatically durable or supervised. It is just scheduled work.

So the real distinction is:

  • foreground work: execution that is directly on the critical path of the caller
  • background work: execution decoupled from the caller’s immediate flow

And separately:

  • foreground thread vs background thread: process-lifetime behavior of an OS thread

Those are related, but not the same.


Short-lived task vs long-running task

Short-lived task

A short-lived task is work that:

  • starts
  • does a bounded amount of computation or async waiting
  • completes relatively quickly

Examples:

  • parse a file
  • fetch one HTTP response
  • transform one message
  • save one record

This is what the default Task/ThreadPool model is optimized for: many relatively short units of work.

Long-running task

A long-running task is work that:

  • persists for a long time
  • may run for minutes, hours, or the life of the process
  • often contains a loop
  • often reacts to cancellation rather than naturally finishing quickly

Examples:

  • queue consumer
  • telemetry pump
  • machine polling loop
  • background cache refresher
  • heartbeat monitor

The key difference is not just duration. It is also lifecycle shape.

A short-lived task is usually “do this unit of work.”

A long-running task is usually “stay alive and keep servicing a responsibility.”

That difference matters because the runtime, scheduler, error handling, shutdown, monitoring, and resource usage are all different.


Cooperative loop design

A long-running worker in managed systems is almost always cooperative, not forcibly preempted by your code.

That means the loop is responsible for:

  • checking cancellation
  • yielding appropriately
  • handling expected failures
  • cleaning up on exit

A good cooperative loop has a shape like this mentally:

  1. Wait for work or interval
  2. Do one iteration of useful work
  3. Handle expected errors locally
  4. Respect cancellation
  5. Yield without spinning
  6. Exit cleanly

The loop should not assume:

  • it can be killed safely at any moment
  • blocking forever is acceptable
  • exceptions can disappear harmlessly
  • shutdown can be ignored

The worker itself participates in lifecycle control.

That is the core idea behind cooperative loop design.


PART 2 — TASK EXECUTION MODEL

How background work runs on ThreadPool by default

Most Task-based work in .NET runs on the ThreadPool by default.

The ThreadPool is a shared pool of worker threads managed by the runtime. Its purpose is to avoid the high cost of creating and destroying OS threads for every unit of work.

When you queue work through common APIs, you are usually saying:

“Schedule this work onto the runtime’s shared worker infrastructure.”

That means:

  • the work may run on an existing pool thread
  • execution is not tied to a specific thread identity
  • many tasks may share a smaller number of threads over time
  • async continuations often resume on ThreadPool threads when no synchronization context is captured

This is efficient for throughput, but it means you must not think of a Task as “my own personal thread.”

A Task is a logical operation, not a thread.


What Task.Run actually does

Task.Run is basically a convenience API that queues a delegate to the default TaskScheduler, which normally uses the ThreadPool.

Conceptually:

  • package delegate into a Task
  • queue it to the default scheduler
  • a ThreadPool worker executes it when available

For synchronous delegates:

  • the delegate actually runs on a pool thread

For async delegates:

  • the initial delegate starts on a pool thread
  • once it hits an await, the underlying operation may suspend
  • when resumed, continuation usually also runs on a ThreadPool thread unless some context is captured

So Task.Run(async () => ...) does not mean “reserve one thread for the whole async method.” It means:

  • start the method on the pool
  • the method may suspend and resume multiple times
  • thread usage is intermittent, not dedicated

This is a very important mental model.


What TaskCreationOptions.LongRunning does

TaskCreationOptions.LongRunning is a hint to the scheduler that the task is likely to be coarse-grained and long-lived, and therefore may be a poor fit for normal ThreadPool scheduling.

With the default scheduler, this often results in a dedicated thread being created for that task rather than consuming a ThreadPool worker.

But there are important caveats:

  • it is a scheduler hint, not a universal contract
  • behavior depends on the scheduler implementation
  • it is typically most relevant for synchronous blocking or compute-heavy long-lived work
  • it is usually not appropriate for async loops that spend most of their time awaiting

Why? Because an async loop that spends most of its time in await does not need a dedicated thread. Giving it one can be wasteful.

So LongRunning is useful when you really mean:

“This work will occupy a thread for a long time; please don’t treat it like a normal short pool work item.”

It is much less useful when you mean:

“This logical operation stays alive for a long time, but mostly waits asynchronously.”

Those are not the same.


When a dedicated thread is created

A dedicated OS thread is typically created in cases like:

1. You explicitly create a Thread

This is the most direct form.

2. You use Task.Factory.StartNew(..., LongRunning, ...)

With default scheduler behavior, this commonly creates a dedicated thread for the delegate.

3. The runtime grows the ThreadPool

This is not the same as dedicated ownership. The pool may add worker threads when needed, but those remain shared pool workers.

4. Special framework internals

Some components internally own dedicated threads or dedicated event loops.

The main senior-level distinction is:

  • dedicated thread: thread identity and ownership are effectively reserved for that execution path
  • ThreadPool work: execution borrows shared worker capacity

That difference affects isolation, latency, scheduling fairness, and resource cost.


PART 3 — WORKER LOOP DESIGN

Structure of long-running async loops

A healthy async worker loop usually has this structure conceptually:

csharp
while (!token.IsCancellationRequested)
{
    await WaitForSignalOrIntervalAsync(token);

    await ProcessOneBatchOrUnitAsync(token);
}

Or for stream/queue consumption:

csharp
await foreach (var item in source.WithCancellation(token))
{
    await HandleAsync(item, token);
}

The good properties here are:

  • explicit cancellation boundary
  • explicit wait boundary
  • explicit work boundary
  • no permanent thread occupation while idle
  • failure points are visible

A worker loop should usually be designed around iterations. Each iteration should be understandable:

  • what wakes it up?
  • what work does it do?
  • when does it yield?
  • what happens on error?
  • what condition exits it?

If those are unclear, the loop becomes fragile.


await inside loops

Using await inside a loop is normal and often ideal.

What actually happens:

  • the method runs until first incomplete await
  • state is stored in the async state machine
  • the thread is returned to the pool
  • when awaited operation completes, continuation is scheduled
  • next iteration continues

This means an async loop can be “always on” logically without monopolizing a thread physically.

That is one of the biggest advantages of modern .NET async design.

But there are subtle concerns:

1. Sequential vs overlapping behavior

A simple await loop is sequential:

  • iteration N finishes before N+1 starts

That is often good for correctness.

If you accidentally start work without awaiting it, iterations may overlap and create:

  • races
  • unbounded concurrency
  • memory growth
  • out-of-order processing

2. Cancellation timing

Cancellation is only observed:

  • when you explicitly check it
  • or when an awaited cancellable operation throws on cancellation

So “using a token” is not enough. The loop must actually reach observation points.


Cancellation-aware loops

A cancellation-aware loop usually has:

  • loop condition using the token or worker state
  • awaited APIs that accept the token
  • explicit handling of OperationCanceledException when cancellation is expected
  • cleanup in finally

Conceptually:

csharp
try
{
    while (true)
    {
        token.ThrowIfCancellationRequested();
        await DoWorkAsync(token);
        await Task.Delay(interval, token);
    }
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
    // expected shutdown path
}
finally
{
    await CleanupAsync();
}

The important thing is not the exact syntax. It is that cancellation is:

  • intentional
  • observable
  • distinguished from failure

A canceled worker should not look like a crash in logs.


Preventing tight CPU-spin loops

One of the most common low-level failures is:

csharp
while (!token.IsCancellationRequested)
{
    if (TryGetWork(out var item))
    {
        Process(item);
    }
}

If no work is available, this loop spins at full speed and burns CPU.

This is a busy loop or spin loop. It is only appropriate in very special low-latency scenarios, and even then usually with careful tuning.

Most of the time you want the worker to:

  • await a signal
  • block on a queue read
  • delay briefly
  • wait on an event/semaphore/channel

A good worker should have an idle strategy. Without one, it becomes a heater.


PART 4 — EXCEPTION BEHAVIOR

What happens when a background task throws

If a background task throws and nobody handles it, the outcome depends on how the task is being observed.

Case 1: caller awaits the task

The exception is captured into the task and rethrown when awaited.

This is the healthiest model because failure is visible.

Case 2: task is stored and later inspected

The exception stays associated with the task and can be observed later via:

  • await
  • .Wait()
  • .Result
  • checking task.Exception

Case 3: fire-and-forget

If the task is started and never awaited or tracked, the exception may remain unobserved.

That is dangerous because:

  • the worker silently dies
  • the system may keep running in a degraded state
  • the only signal may be a late unobserved exception event or log, or nothing operationally useful

The biggest practical problem is usually not process crash. It is silent loss of responsibility.

A loop that was supposed to keep polling, flushing, or consuming simply stops existing.


Unobserved task exceptions

A Task captures exceptions. If nobody ever observes them, the runtime can raise TaskScheduler.UnobservedTaskException when the task is finalized.

Historically, behavior around this changed across .NET generations, but the important engineering point is:

  • unobserved does not mean safe
  • delayed finalizer-time notification is not a supervision mechanism

You should not rely on unobserved exception behavior for correctness.

From a production perspective, the real problem is this:

  • by the time an unobserved exception becomes visible, the worker may already have been dead for a long time

So the design rule is:

  • every important background task must have an owner
  • every owned task must have an observation path

Why fire-and-forget is dangerous

Fire-and-forget is dangerous because it throws away:

  • completion status
  • exception visibility
  • cancellation coordination
  • shutdown participation
  • backpressure control

It is tempting because it looks simple:

csharp
_ = DoSomethingAsync();

But unless the operation is truly disposable, you just created a detached execution branch with unclear ownership.

That creates questions:

  • who knows if it failed?
  • who cancels it?
  • who waits for it during shutdown?
  • how many may exist at once?
  • what prevents infinite accumulation?

A senior engineer treats “fire-and-forget” as a code smell unless the lifetime and consequences are very carefully bounded.


Supervision patterns conceptually

Supervision means:

  • workers are started intentionally
  • failures are observed
  • restart policy is deliberate
  • escalation is defined

Conceptually, a supervisor is responsible for:

  1. starting a worker
  2. monitoring its task
  3. logging failures
  4. deciding whether to restart, back off, stop the app, or degrade functionality

This matters because background workers are not self-managing.

A robust system distinguishes:

  • transient iteration failures inside the loop
  • fatal worker failure that terminates the loop
  • repeated crash loops
  • shutdown cancellation

Without supervision, workers become fragile invisible processes inside your process.


PART 5 — THREADPOOL IMPACT

How long-running work affects ThreadPool availability

The ThreadPool is a shared resource. If you keep many pool threads occupied with long blocking work, fewer threads remain available for:

  • async continuations
  • timer callbacks
  • request handling
  • queue processing
  • UI-adjacent background work
  • other framework operations

This is where engineers get confused:

“It’s background, so it’s fine.”

No. If it runs on the ThreadPool, it competes with other ThreadPool work.

A long-running worker that is mostly async and awaiting is cheap. A long-running worker that blocks a pool thread is expensive.

That distinction is critical.


Starvation scenarios

ThreadPool starvation happens when queued work cannot get timely access to worker threads because too many existing workers are blocked or occupied.

Typical causes:

  • calling .Result / .Wait() on async operations
  • blocking on I/O inside pool threads
  • many long-running synchronous loops on the pool
  • sync-over-async deadlock patterns
  • too many producers creating more work than consumers can drain

Symptoms:

  • timer callbacks run late
  • continuations resume slowly
  • throughput collapses
  • latency spikes
  • application looks “hung” even though CPU may not be fully utilized

The ThreadPool can inject more threads, but hill-climbing and thread injection are reactive, not magical. Recovery may lag behind the workload spike.

So the design principle is:

  • avoid parking pool threads on long blocking waits unless you really mean to consume thread capacity

Blocking vs non-blocking loops

Blocking loop

A blocking loop keeps a real thread occupied while waiting.

Examples:

  • Thread.Sleep
  • blocking socket read
  • WaitHandle.WaitOne
  • synchronous queue wait
  • polling with blocking SDK calls

This is simple sometimes, but expensive in thread terms.

Non-blocking async loop

A non-blocking loop awaits operations:

  • await Task.Delay
  • await channel.Reader.ReadAsync
  • await socket.ReceiveAsync
  • await semaphore.WaitAsync

This does not occupy a thread during the wait.

That is why async loops scale so much better when you have many mostly-idle responsibilities.

But blocking is not always wrong. Sometimes:

  • SDK is synchronous only
  • hardware API is blocking
  • isolation is more important than pool efficiency

In those cases, a dedicated thread may be the better answer than blocking the pool.


PART 6 — TIMERS, DELAYS, AND SCHEDULING

Task.Delay vs Thread.Sleep

Thread.Sleep

  • blocks the current thread
  • thread cannot do anything else during the sleep
  • consumes thread occupancy
  • usually bad inside ThreadPool workers unless intentionally throttling on a dedicated thread

Task.Delay

  • creates an asynchronous timer-based wait
  • does not block a thread while waiting
  • continuation resumes later when timer fires
  • preferred in async loops

That makes Task.Delay the normal choice for async workers.

The mental model:

  • Thread.Sleep: “hold this thread idle”
  • Task.Delay: “resume this operation later”

Huge difference.


Timer-based loops vs while loops

There are several patterns.

1. while loop with Task.Delay

Simple and explicit:

csharp
while (!token.IsCancellationRequested)
{
    await DoWorkAsync(token);
    await Task.Delay(interval, token);
}

Good when:

  • one iteration should not overlap the next
  • logic is easy to reason about
  • you want explicit flow

2. timer callback model

Examples: System.Threading.Timer, PeriodicTimer

Timer model is useful when work is conceptually “triggered by schedule.”

But callback timers can introduce overlap if the next tick fires before previous work completes.

That is a major source of subtle bugs:

  • reentrancy
  • concurrent access
  • accumulating work
  • race conditions

PeriodicTimer is often easier to reason about for async code because it gives you an awaitable tick model instead of raw callback reentrancy.


Drift and scheduling accuracy considerations

No timer strategy is perfectly precise in a general-purpose OS process.

Why drift happens:

  • scheduler latency
  • GC pauses
  • ThreadPool contention
  • CPU saturation
  • long work duration
  • timer resolution limits
  • clock adjustments depending on API used

Example:

csharp
while (...)
{
    await DoWorkAsync();
    await Task.Delay(TimeSpan.FromSeconds(1));
}

This does not mean “run exactly once every second.” It means:

  • do work
  • then wait one second
  • so actual period = work duration + delay + scheduling overhead

That is accumulated drift.

If you need fixed-rate scheduling, you must compute based on a target schedule, not just “delay after work.”

There are two common modes:

Fixed-delay

Wait after each iteration finishes.

  • simpler
  • no overlap
  • naturally slows under load
  • drifts

Fixed-rate

Try to run at specific timestamps.

  • more accurate schedule
  • more complex
  • may need catch-up or skip logic
  • can become unstable if work exceeds period

Senior engineers choose deliberately based on system behavior, not habit.


PART 7 — SHUTDOWN & CANCELLATION

How to stop worker loops safely

Safe stop means:

  1. signal no new work should start
  2. wake any waits
  3. let in-flight work observe cancellation
  4. wait for worker completion if appropriate
  5. clean up resources
  6. log final state

A worker should usually not be stopped by brute force. That risks:

  • corrupted state
  • partially written files
  • half-completed protocol exchanges
  • leaked handles
  • inconsistent UI or component state

Instead, stop should be cooperative and staged.


Cancellation propagation

Cancellation propagation means the same stop intent flows through the stack:

  • host/application wants shutdown
  • worker receives token
  • worker passes token to waits, I/O, and inner operations
  • inner operations respect it or fail quickly

A token that is never passed down is mostly decorative.

A token that is passed down but never checked is also mostly decorative.

Good propagation means cancellation becomes part of the method contract:

  • this operation can stop early
  • this wait can be interrupted
  • this cleanup distinguishes cancellation from error

That is how shutdown becomes predictable.


Graceful shutdown and cleanup

Graceful shutdown is not just “cancel token.”

It is:

  • stop intake
  • drain or abandon queued work intentionally
  • flush what must be flushed
  • release unmanaged resources
  • dispose timers / streams / handles
  • publish final status if needed

Typical cleanup belongs in finally, because shutdown may occur through:

  • normal completion
  • expected cancellation
  • unexpected exception

A worker that has no cleanup strategy is usually not production-ready.

The senior question is:

  • what state must be left consistent if the loop exits now?

PART 8 — COORDINATION WITH UI / OTHER COMPONENTS

Publishing results from worker loops

Workers often produce:

  • progress
  • telemetry
  • state updates
  • data batches
  • alerts

The clean design is to publish results through controlled boundaries:

  • channels
  • event streams
  • message buses
  • thread-safe state stores
  • dispatcher-marshaled UI updates

The wrong design is often:

  • background loop directly mutates everything it can reach

That creates hidden threading assumptions.

A good rule is:

  • worker owns background acquisition/processing
  • publication path owns safe delivery to consumers

Avoiding cross-thread UI access

UI frameworks like WPF require UI-bound objects to be accessed on the UI thread.

So a worker loop must not directly update UI elements from a ThreadPool or dedicated worker thread.

Instead, it should:

  • send data to the UI thread via dispatcher/synchronization context
  • update thread-safe intermediate state
  • let the UI observe changes safely

The mental model is:

  • worker produces data
  • UI thread renders data

Not:

  • worker reaches into UI controls directly

Violating this causes:

  • cross-thread exceptions
  • race conditions
  • non-deterministic UI behavior

Synchronization issues with shared state

Long-running workers often share:

  • status flags
  • queues
  • caches
  • last-known measurements
  • configuration snapshots

The danger is not only “data race” in a textbook sense. It is also:

  • stale reads
  • inconsistent compound state
  • lock contention
  • deadlocks
  • out-of-order visibility
  • accidental overlap between worker and UI actions

You need to define:

  • who owns mutation
  • what thread(s) may read
  • whether access is immutable, locked, atomic, or message-based

Many background execution bugs are really state ownership bugs.


PART 9 — COMMON LOW-LEVEL PITFALLS

Zombie tasks

A zombie task is a worker that is still technically alive but no longer doing useful work, or is detached from supervision.

Examples:

  • loop is stuck forever awaiting something that never completes
  • task faulted but owner forgot it existed
  • worker is spinning in error handling without real progress
  • restart logic started a replacement but old one still lingers

Zombie workers are dangerous because dashboards may show “running” while the responsibility is effectively dead.

You need health signals based on:

  • last successful iteration
  • last consumed message
  • last heartbeat
  • loop progress counters

Not just “task exists.”


Leaked cancellation sources

CancellationTokenSource owns resources and callback registrations. If you keep creating them and never disposing when appropriate, you can leak:

  • registrations
  • timers
  • memory
  • object graphs kept alive by callbacks

Common mistakes:

  • creating linked token sources repeatedly in loops
  • timeout CTS per operation without disposal
  • storing CTS forever after the operation ends

CTS usage should have ownership and lifetime discipline.


Background loops swallowing exceptions

A very common anti-pattern:

csharp
while (...)
{
    try
    {
        await DoWorkAsync();
    }
    catch
    {
    }
}

This is catastrophic because:

  • failures disappear
  • bug signals are lost
  • loop may enter infinite rapid failure mode
  • operational diagnosis becomes impossible

Even if you catch broadly to keep the worker alive, you must:

  • log
  • classify
  • back off if repeated
  • decide when failure is fatal

A worker that swallows exceptions is usually lying about health.


Overusing LongRunning

LongRunning is often used as a ritual rather than a reasoned decision.

That causes problems:

  • excessive thread creation
  • wasted memory and context-switch overhead
  • false belief that async work needs dedicated threads
  • fragmentation of execution model

Use it when the work truly behaves like:

  • long-lived
  • synchronous/blocking
  • thread-occupying
  • poor fit for shared pool scheduling

Do not use it just because the logical operation lasts a long time.

An async queue consumer that awaits most of the time usually does not need LongRunning.


Busy-wait loops

Busy-wait loops are often accidental:

  • polling a flag without wait
  • repeatedly checking queue count
  • retrying immediately on empty state
  • catching an exception and continuing instantly

These loops create:

  • CPU burn
  • battery drain
  • scheduler pressure
  • noisy logs
  • unfairness to other work

Every loop should have a clear answer to:

  • what happens when there is no work?
  • what happens when work fails repeatedly?
  • what is the idle strategy?
  • what is the retry/backoff strategy?

PART 10 — SENIOR ENGINEER MENTAL MODEL

How to reason about always-on background execution

Think of each worker as a small service inside your process.

It has:

  • purpose
  • input source
  • execution model
  • failure modes
  • health signals
  • shutdown behavior
  • ownership

That mindset is much better than thinking:

  • “it’s just a loop”
  • “it’s just a Task.Run”
  • “it runs in the background somehow”

For every always-on worker, ask:

  1. What wakes it up?
  2. What resource does it consume while idle?
  3. What happens on failure?
  4. Who observes completion?
  5. How does it stop?
  6. Can it overlap with itself?
  7. What proves it is healthy?
  8. What backpressure exists?
  9. What shared state does it touch?
  10. Is ThreadPool or dedicated-thread execution more appropriate?

If you can answer those clearly, you understand the worker.


How to detect unhealthy worker behavior

Do not rely only on logs saying “started successfully.”

Healthy workers should expose signals like:

  • last successful iteration timestamp
  • consecutive failure count
  • queue depth / lag
  • average iteration time
  • restart count
  • current state: idle, working, stopping, faulted
  • last published heartbeat

Unhealthy patterns include:

  • no forward progress
  • high CPU while “idle”
  • repeated fault/restart cycles
  • increasing lag
  • ever-growing memory
  • shutdown taking too long
  • workers that appear alive but produce nothing

A senior engineer monitors progress, not merely existence.


How to design reliable supervision and recovery

Reliable supervision starts with accepting this truth:

background execution is not reliable just because it is in-process and managed.

You must design for:

  • worker startup failure
  • mid-run iteration failure
  • fatal loop termination
  • dependency outage
  • cancellation during I/O
  • restart storms

A good supervision model usually includes:

  • explicit worker ownership
  • one place that starts and stops workers
  • structured exception logging
  • clear fatal vs retryable error policy
  • restart backoff
  • health reporting
  • escalation when a critical worker cannot recover

Not every worker should auto-restart forever. Sometimes the correct action is:

  • disable a feature
  • mark subsystem unhealthy
  • require operator intervention
  • fail fast at process level if the worker is mission-critical

The right policy depends on business criticality.


How to balance simplicity vs control

The mature approach is:

  • use the simplest execution model that preserves correctness
  • only add control mechanisms when the failure modes justify them

Examples:

Simple is enough when:

  • one worker
  • one responsibility
  • one sequential loop
  • clear cancellation
  • clear exception path
  • modest load

More control is needed when:

  • many workers compete for pool threads
  • blocking SDKs are involved
  • restart policy matters
  • health must be reported
  • schedule accuracy matters
  • overlapping work is dangerous
  • shutdown must drain safely
  • UI coordination is sensitive

Overengineering every worker into a mini actor system is wasteful. But treating long-running execution as “just launch a task” is naive.

The balance comes from understanding:

  • thread cost
  • scheduling behavior
  • async suspension
  • failure visibility
  • ownership
  • lifecycle

That is the real senior mental model.


Final synthesis

The deepest idea here is this:

A background worker in .NET is not primarily about Task.Run, while, or CancellationToken. It is about lifecycle ownership of always-on responsibility.

At runtime, you are balancing five forces:

  1. Execution model ThreadPool vs dedicated thread, blocking vs async

  2. Correctness sequencing, shared state, non-overlap, cancellation safety

  3. Failure visibility exception observation, supervision, health reporting

  4. Resource behavior thread usage, CPU spin, memory growth, timer drift

  5. Shutdown discipline cooperative stop, cleanup, draining, final consistency

If you understand those five deeply, you can reason clearly about almost any .NET background processing design.

If you want, I can next turn this into a senior-interview Q&A sheet with questions like “When would you use LongRunning?”, “How does a worker silently die?”, and “How would you supervise always-on loops in a WPF or service process?”

Docs-first project memory for AI-assisted implementation.