Task 80 — Graceful shutdown and drain
Phase: 5
Initiative: 45
Status: done
Owner: hercules-coder
Started: 2026-08-16
Completed: 2026-08-16
Slug: graceful-shutdown-drain
Goal
Нет graceful shutdown/drain: IHostApplicationLifetime injected в McpServerHost но не используется. LifecycleService.DrainAgentAsync (:185-215) устанавливает Draining state но ничего не hooks — новые запросы продолжают приниматься. In-flight запросы и background services отменяются abruptly via CancellationToken при shutdown. При K8s rolling deploy in-flight запросы теряются.
Acceptance criteria
Sub-tasks
Program.cs+WebApi/Program.cs— зарегистрироватьIHostApplicationLifetime.ApplicationStoppingcallback → вызватьILifecycleService.DrainAgentAsync(localAgentId, ct). (черезDrainHostedService—IHostedService.StopAsyncawaits drain, host ждёт)Lifecycle/LifecycleService.cs:185-215—DrainAgentAsyncдолжен: (1) установитьDrainingstate, (2) перестать принимать новые запросы (см. ниже), (3) ждать in-flight с timeout (default 30s), (4) cancel remaining, (5) установитьStopped. (черезIAgentLifecycleState+IInFlightTracker)Agent/AgentCore.cs— в началеHandleAsyncCoreпроверить_lifecycle.IsDraining(sessionId); если Draining → return 503/degraded response сразу. (черезIAgentLifecycleState, бросаетLifecycleDrainingException)Hercules.WebApi/Program.cs— middleware: еслиDraining→ return503 Service Unavailable+Retry-After: 30header (даёт K8s сигнал подождать). (DrainMiddleware)- In-flight tracking:
IInFlightTracker(new) —Increment(sessionId)/Decrement(sessionId)/WaitForEmptyAsync(timeout).AgentCore.HandleAsyncCoreоборачивается вusing var tracker = _inFlight.Begin(sessionId). (Agent/InFlightTracker.cs, используетInterlocked+TaskCompletionSource) DrainAgentAsync→await _inFlight.WaitForEmptyAsync(TimeSpan.FromSeconds(30))→ если timeout, log warning + cancel.Mcp/McpServerHost.cs:26-39— убрать unused_appLifetimeinjection ИЛИ использовать его для graceful MCP shutdown. *(StopApplication()вызывается только если cancellation не запрошен — избегаем redundant stop) *Offline/OfflineSyncService.cs:158-163—OnReconnectedhandler: при shutdown (ApplicationStopping) не запускать flush. Проверятьct.IsCancellationRequestedв handler. (уже сделано черезcombinedCts, добавлен явныйIsCancellationRequestedcheck)- Background services (
DegradationManager,MeshBackendHealthMonitor,CapabilityHealthService,BackupScheduler,CacheServicecleanup timer) — все должны honourApplicationStoppingtoken и complete gracefully (stop timers, flush pending). (проверено — все используютstoppingTokenизExecuteAsync/StopAsync) Console.CancelKeyPress(Program.cs:620-625) — координировать сIHostApplicationLifetime.StopApplication(). (вместоcts.Cancel()зовёмIHostApplicationLifetime.StopApplication())appsettings.json—Shutdown.DrainTimeoutSec(default 30),Shutdown.CancelInFlightAfterDrain(default true).- Unit-тест:
DrainAgentAsync→ новыеHandleAsyncвозвращают 503; in-flight completes normally. - Unit-тест: in-flight не завершается за timeout →
DrainAgentAsynccancels + logs. dotnet build+dotnet testpass.
Scope / Likely files
src/agent/Program.cs, src/agent/Hercules.WebApi/Program.cs, src/agent/Lifecycle/LifecycleService.cs, src/agent/Agent/AgentCore.cs, src/agent/Agent/IInFlightTracker.cs (new), src/agent/Agent/InFlightTracker.cs (new), src/agent/Mcp/McpServerHost.cs, src/agent/Offline/OfflineSyncService.cs, src/agent/appsettings.json
Dependencies
- блокирует / опирается на: task_057 — lifecycle-management
- блокирует / опирается на: task_075 — di-lifetime-fixes (session state для in-flight tracking)
Risks / Rollback
Drain timeout 30s может задержать shutdown; configurable. In-flight tracker добавляет overhead на каждый request (Interlocked Increment/Decrement — minimal). Rollback: убрать ApplicationStopping callback (но in-flight запросы теряются при deploy).
Links
- Backlog: ../backlog.md
- Roadmap (EN): ../../ROADMAP-EN.md
- Roadmap (RU): ../../ROADMAP-RU.md
Implementation notes (2026-08-16)
Behaviour delivered
- State decoupling — extracted
IAgentLifecycleState(withAgentLifecycleStateHolder) so the lifecycle state is shared betweenLifecycleService(writer) andAgentCore/DrainMiddleware(readers) without aLifecycleService → AgentCorecircular dep. - In-flight tracking —
IInFlightTracker/InFlightTrackerexposeBegin()(returns ausing-friendlyIDisposable) andWaitForEmptyAsync(timeout, ct). Lock-free on the hot path (Interlockedcounter); the wait path uses a per-callTaskCompletionSource+ signal queue so multiple waiters all unblock when the counter hits zero. LifecycleService.DrainAgentAsync— now actually awaits in-flight:Running → Draining → (wait) → Stopped.DrainedCleanly+RemainingInFlightare recorded in theMetadataof the returnedLifecycleActionResult.AgentCore.HandleAsyncCore— throwsLifecycleDrainingExceptionat the top of the body whenIAgentLifecycleState.IsShuttingDown; the rest of the body is wrapped inusing var _inFlightScope = _inFlight?.Begin()so every request (success, exception, cancellation) releases its slot.DrainMiddleware— registered before auth/rate-limit so 503s come out clean. Exempts/api/health*,/api/ready,/api/live,/agent.manifest.json,/agent-card.jsonso K8s liveness probes still work.DrainHostedService—IHostedService.StopAsynccallsDrainAgentAsync. The .NET host awaitsStopAsync, so the process won’t exit until in-flight drains.McpServerHost— only callsIHostApplicationLifetime.StopApplication()when the cancellation token wasn’t already requested (avoids redundant stop signal during host shutdown).OfflineSyncService.OnReconnected— early-returns whenstoppingToken.IsCancellationRequestedto avoid starting a half-completed flush during shutdown.Program.cs(CLI) —Console.CancelKeyPressnow also callsIHostApplicationLifetime.StopApplication()so the drain hosted service fires.appsettings.json— newShutdownsection (Enabled,DrainTimeoutSec,CancelInFlightAfterDrain,PostStopRetryAfterSec).
Validation
dotnet build src/agent/Hercules.csproj— 0 errors, 15 pre-existing warningsdotnet build src/agent/Hercules.WebApi/Hercules.WebApi.csproj— 0 errors, 1 pre-existing warningdotnet build tests/Hercules.Agent.Tests/Hercules.Agent.Tests.csproj— 0 errors- New tests: 25/25 pass
InFlightTrackerTests(7) — Begin/Dispose, fast path, wait resolution, timeout, multiple waiters, double-dispose, post-timeout signalAgentLifecycleStateHolderTests(3) — defaults, SetState, event firingLifecycleDrainTests(6) — state transition, in-flight wait, timeout, double-drain rejection, stop-after-drainAgentCoreShutdownTests(5) — Draining/Stopped/Decommissioned throw, Running works, counter wrapsDrainMiddlewareTests(4) — 503 + Retry-After, fallback Retry-After, exempt paths, normal pass-through
- Full test suite: 1839 passed, 9 failed — all 9 failures pre-existing
(
OtelServiceTests×5,NumericValidatorTests×2,RedisTaskQueueTests×1,WasmToolTests×1 — environmental: no Otel/Redis/WASM runtime in test host).