Repository navigation
Expand file tree
/
Copy pathOrchestratorService.cs
More file actions
4926 lines (4471 loc) · 231 KB
/
Copy pathOrchestratorService.cs
File metadata and controls
4926 lines (4471 loc) · 231 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using System.Collections.Concurrent;
using System.Collections.Frozen;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using CodeyBox.Core;
namespace CodeyBox.Orchestrator;
/// <summary>
/// Background service that drives a concurrency-capped, spawn-paced worker
/// pool over the task queue. A single dispatch loop dequeues items; a
/// <see cref="SemaphoreSlim"/> of size <see cref="OrchestratorOptions.MaxConcurrentWorkers"/>
/// caps how many run simultaneously; <see cref="OrchestratorOptions.MinSpawnInterval"/>
/// enforces a minimum wall-clock gap between successive spawns.
/// </summary>
public sealed partial class OrchestratorService : BackgroundService, IAgentRunningCounters, IAgentSessionSlotGate, IShutdownDispatchGate, IWorkerPoolRecoverySlotReleaser, IWorkerPoolOccupancy, IInfrastructureDeferralScheduler, IRefactorProjectGateStatusProvider, IRefactorProjectDispatchGate
{
private const int DispatchPickupCandidatePageSize = 128;
private const int DispatchPickupCandidateScanBudget = DispatchPickupCandidatePageSize * 4;
private static readonly TimeSpan DispatchPickupCandidateScanBudgetWakeDelay = TimeSpan.FromMilliseconds(250);
// A promotion can lose a state race to another worker or operator action.
// Restart a small number of times so the picker observes the fresh row state
// without spinning indefinitely on a hot item.
private const int MaxQuotaPromotionRaceSelectionRestarts = 3;
// Flipped by PauseDispatch() — the SandboxShutdownTeardownService calls it
// from its IHostedLifecycleService.StoppingAsync BEFORE it begins freezing
// VMs, so the dispatch loop stops picking up new items and creating new
// sandboxes that would race the snapshot. In-flight workers continue to
// completion until the BackgroundService cancellation token fires later in
// the shutdown sequence. Read on every dispatch loop iteration.
private int _shutdownDispatchPaused;
public bool IsDispatchPaused => Volatile.Read(ref _shutdownDispatchPaused) != 0;
public void PauseDispatch()
{
if (Interlocked.Exchange(ref _shutdownDispatchPaused, 1) == 0)
{
_log.LogInformation(
"OrchestratorService: dispatch paused for shutdown — no new work will be picked up");
// Wake the dispatch loop so it observes the flag immediately rather
// than blocking on the next natural kick.
// The loop checks IsDispatchPaused immediately after dequeue, before
// it can call PickNextEligibleAsync. The observed kick is the guard
// against a channel-writer fault during shutdown surfacing as an
// unobserved task exception — exactly the wedge case this gate
// exists to prevent.
KickDispatchWakeUnobserved("PauseDispatch");
}
}
/// <summary>
/// True while a host-level provider-transient pause is active: the same
/// transient signature recently hit several agents, so the cause is
/// likely host/network-level and new spawns hold briefly instead of
/// dispatching into the blip. Unlike <see cref="IsDispatchPaused"/> this
/// is temporary and self-expiring — the dispatch loop waits it out and
/// re-checks rather than stopping.
/// </summary>
public bool IsHostTransientPaused() =>
_hostTransientPause?.IsPaused(DateTimeOffset.UtcNow) ?? false;
private readonly ITaskQueue _queue;
private readonly IWorkItemStore _store;
private readonly IPipelineRunner _pipeline;
private readonly CancellationRegistry _cancellations;
private readonly OrchestratorOptions _opts;
private readonly ILogger<OrchestratorService> _log;
private readonly AgentClassRouter? _router;
private readonly IQuotaRetryAdmissionRouter? _quotaRetryAdmissionRouter;
private readonly IProjectRepository? _projects;
private readonly IQueueController? _queueController;
private readonly IAgentDispatchAvailability? _dispatchAvailability;
private readonly IQuotaRetryDispatchPromoter? _quotaRetryDispatchPromoter;
private readonly IWebhookDispatcher? _webhooks;
private readonly IWorkerRegistry? _workerRegistry;
private readonly DeadWorkerOptions? _deadWorkerOpts;
private readonly DeadWorkerReaper? _reaper;
private readonly WorkItemRepoReaper? _repoReaper;
private readonly IStartupRecoveryInputBarrier? _startupRecoveryBarrier;
private readonly IStartupInitialRecoverySink? _startupRecoveryCompletion;
private readonly ReleaseService? _releaseService;
// Consulted by AgentPauseRetryFromForPickup to decide whether a Queued
// item resumes into the planning phase or straight into work. Reads through
// IKnob.GetPipelineLifecycle so the check stays local to the knob descriptor
// and follows the same framework contract PipelineRunner.ShouldUsePlanningPhase
// consumes. Null in narrow test bootstraps that don't wire the registry;
// the fallback path references PlanKnob's public constants so a missing
// registry still routes plan=on Queued items to planning.
private readonly IKnobRegistry? _knobRegistry;
// B1 baseline-pinning: stamps the work/headless baseline ref at pickup time
// so matching work-profile sandboxes keep using that baseline even after an
// operator edits config. Later phases with different profiles or graphical
// flavor resolve their own target baselines rather than reusing this pin.
// For providers that don't model baselines (process / bubblewrap) the DI
// factory hands in NullBaselineImageResolver which returns null for every
// resolve — pickup proceeds without a stamp.
private readonly IBaselineImageResolver _baselineResolver;
private readonly OrchestratorProgressClock _progressClock;
// Shared swappable holder. Both this service AND PipelineRunner (the
// pickup-time rebase-resolver's cap-aware router) read through the same
// AgentConcurrencySnapshot, so ApplyAgentConcurrencyReload's swap is
// visible to both consumers — without the shared holder, the resolver
// would keep gating against the pre-reload caps until process restart.
// In-flight items are not retroactively gated; caps are only consulted at
// dispatch time.
private readonly AgentConcurrencySnapshot _concurrencySnapshot;
// Live in-flight agent CLI session counts keyed by routed agent instance,
// split into work vs audit sessions. For class-routed items the work count
// is incremented atomically inside AgentClassRouter via the
// IAgentSlotGate (this service); for direct-agent items the orchestrator
// reserves the slot itself after routing. LLM auditor sessions take audit
// reservations through the same gate so the route's MaxConcurrent bounds
// every session the provider sees. The lock-guarded machinery lives in
// OrchestratorService.AgentSessionSlots.cs; the work reservation is bound
// to the item (RegisterItemWorkSlot) so the pipeline can suspend it around
// the auditor fan-out. Surfaced via /concurrency and consumed by the
// rate-aware gate.
// Quota escrow leases keyed by work item id string. Populated when class
// routing authorises a dispatch with a reservation ledger wired; the outer
// finally reconciles/releases on every exit path (success, failure,
// cancellation, deferral), and the recovery reaper releases by work item
// id when a worker died without running its finally. Entries are removed
// exactly once by whichever path runs first, so a dead worker cannot pin
// headroom past the ledger TTL.
private readonly ConcurrentDictionary<string, QuotaReservationLease> _quotaReservationsByWorkItem =
new(StringComparer.Ordinal);
private readonly QuotaReservationLedger? _reservationLedger;
private readonly IWorkItemCostStore? _costStore;
private readonly AgentBurnEstimatorOptions? _burnEstimatorOptions;
// Re-pickup delay applied when a direct-agent item hits its per-agent
// cap. Class-routed items use QuotaRouterOptions.CapRetryRecheckInterval
// (the router surfaces it via AgentRoutingDecision.SuggestedRecheckIn).
// Short enough that the deferred item is reconsidered as soon as another
// worker on the same agent finishes; long enough not to busy-loop.
// Read from the shared QuotaRouterOptions singleton so the hot-reload
// coordinator's edits take effect without restart.
private readonly QuotaRouterOptions? _quotaRouterOptions;
private readonly BudgetDeferralRecheckSnapshot? _budgetDeferralRecheck;
// Tracks work item IDs that are currently being processed by a worker.
// Guards against double-execution when two workers both enqueue the same
// item (e.g., both see it as the last satisfied dependent simultaneously).
// Also used by the priority-aware pickup query to skip items that have been
// dispatched but whose persisted state has not yet flipped out of Queued.
private readonly ConcurrentDictionary<WorkItemId, byte> _activeItems = new();
// One idempotent lease per spawned worker-pool slot. The worker task's
// finally block normally releases the lease; the dead-worker reaper can
// release the same lease early by registry worker id if it claims a stale
// row for this process and decides not to re-dispatch the item. The lease's
// single-shot release prevents a later task exit from double-decrementing
// _currentlyRunning or over-releasing the semaphore.
private readonly ConcurrentDictionary<string, WorkerSlotLease> _workerSlotsByRegistryId = new(StringComparer.Ordinal);
// Every unreleased lease, keyed by reference, regardless of whether the
// worker registered a registry row. Powers GetWorkerSlotOccupancy (slot
// identities for diagnosis) and the orphaned-slot reconciliation, which
// reclaims leases whose worker exited without releasing. Entries are added
// before Task.Run and removed exactly once by the single-shot release, so
// the set always mirrors the permits the pool believes are occupied.
private readonly ConcurrentDictionary<WorkerSlotLease, byte> _liveWorkerSlots = new();
// Worker task per unreleased lease. A lease whose task reached a terminal
// state but was never released is proof the worker exited without
// releasing — the primary orphan signal (incident 2026-09-07).
private readonly ConcurrentDictionary<WorkerSlotLease, Task> _workerTasks = new();
// Reports how many sandboxes currently exist (created but not disposed).
// Consulted by the orphaned-slot reconciliation so a slot backing live
// provisioning work is never mistaken for an orphan. Defaults to the
// process-wide live-sandbox counter; tests inject a fixed value.
private readonly Func<long> _activeSandboxCountProvider;
// Tracks work item IDs that are currently sleeping in a deferred-requeue
// delay (budget / quota / project-pause defer). They remain Queued in the
// store; the pickup query skips them until the delay fires and removes them.
private readonly DeferredItemRegistry _deferredItems = new();
// In-process dispatcher-evaluation record feeding the item-stale
// watchdog's dispatch-liveness guard (and queue status): every deferral
// notes an evaluation, every pickup clears the quota episode. Deferred
// membership is answered live from _deferredItems so the two cannot drift.
private readonly DispatchItemLivenessTracker? _dispatchLiveness;
private readonly ProviderTransientCorrelationTracker? _hostTransientPause;
private readonly BaseBrokenConditionTracker? _baseBrokenConditions;
/// <summary>Test/diagnostic hook for the dispatch-liveness record.</summary>
internal DispatchItemLivenessTracker? DispatchLiveness => _dispatchLiveness;
// Project-scoped drain claims created when a queued Refactor reaches its
// normal dispatch turn but the project still has in-flight work. While the
// claim is active, fresh same-project non-refactor starts are held so the
// project can drain.
// The claim is rebuilt from durable queued refactor rows after restart.
private readonly ConcurrentDictionary<string, RefactorDrainClaim> _refactorDrainClaims = new(StringComparer.Ordinal);
// Concurrency gate: at most MaxConcurrentWorkers items running at once.
// Resizable at runtime via ApplyWorkerPoolReload so the operator can
// hot-adjust the global pool size without restarting the orchestrator —
// SemaphoreSlim has no in-place resize primitive, hence the wrapper.
private readonly ResizableConcurrencyGate _concurrencyGate;
// Spawn pacing: UTC ticks of the last worker spawn (0 = never).
// Written under a lock so the read-modify-write is atomic.
private long _lastSpawnAtTicks = 0;
private readonly object _spawnTimeLock = new();
// Live spawn-pacing floor (CodeyBox:WorkerPool:MinSpawnInterval), seeded
// from OrchestratorOptions at startup and resizable via
// ApplyMinSpawnIntervalReload. Stored as ticks so the dispatch loop reads
// it with a single volatile load; the record on _opts stays the startup
// snapshot for diagnostics.
private long _minSpawnIntervalTicks;
// Live "in-flight before fresh" ordering preference
// (CodeyBox:WorkerPool:PreferInFlightOverFresh), seeded from
// OrchestratorOptions at startup and switchable via
// ApplyPreferInFlightOverFreshReload. 1 = pickup ordering ranks
// past-work-phase items ahead of equal-priority fresh starts, tie-broken
// by queue position; 0 = legacy ordering (finishing bucket, priority,
// created_at). Independent of the cap-deferral release wake, which is
// unconditional. Stored as an int for a single volatile read on the
// dispatch path.
private int _preferInFlightOverFresh;
// Worker index counter — monotonically increasing, used for log identity.
private int _nextWorkerId = 0;
// Snapshot for the /workers/status endpoint.
private int _currentlyRunning = 0;
// Count of background deferral tasks currently waiting to re-enqueue items.
private int _pendingDeferrals = 0;
private const int DeferralWarningThreshold = 100;
// No-progress re-dispatch backoff (incident 2026-06-04). When a worker is
// dispatched but the pickup returns without advancing the item's state
// (e.g. a poisoned work branch whose pickup phase no-ops, or a worker-owned
// state such as Working that the pipeline hands straight back), the
// slot-release dispatch wake would re-pick the still-dispatchable item
// instantly — a tight ~500/sec spawn loop that fills the disk (incident
// 2026-09-07). MinSpawnInterval stays 0 for normal operation; instead
// we count consecutive no-progress re-dispatches per item and defer with an
// escalating backoff (base → max cap, both configurable), turning a loop
// into delayed/no work rather than a high-load event. After a cap the item
// is Failed so a genuinely stuck item is cleared instead of looping forever.
// Reset to 0 on any progress. Backoff bounds are hot-configurable via
// CodeyBox:WorkerPool:NoProgressBackoffBase/Max and
// CodeyBox:WorkerPool:MaxNoProgressRedispatches (startup-bound).
private readonly ConcurrentDictionary<WorkItemId, int> _noProgressRedispatch = new();
/// <summary>
/// Fallback deferral interval when <c>QuotaRouterOptions</c> is not wired
/// (DI omits it). 15s keeps the deferred item visible without busy-looping.
/// Matches the hardcoded default that <see cref="AgentClassRouter"/>
/// surfaces through <c>QuotaRouterOptions.CapRetryRecheckInterval</c>.
/// </summary>
private static readonly TimeSpan DefaultCapRetryRecheckInterval = TimeSpan.FromSeconds(15);
private static readonly TimeSpan SlotReleasedDispatchWakeRetryDelay = TimeSpan.FromMilliseconds(100);
private static readonly TimeSpan SpawnPacingPausePollInterval = TimeSpan.FromMilliseconds(50);
private static readonly TimeSpan SpawnPacingPauseObservationWindow = TimeSpan.FromMilliseconds(250);
// WaitIfPausedAsync re-checks the queue controller's in-memory volatile
// state field on this cadence. Keeping it short (250ms) means a Resume
// takes effect promptly without an extra signal channel — matches the
// spawn-pacing pause poll. The read is a constant-time volatile field
// load on SqliteQueueController so cadence is not a contention concern.
private static readonly TimeSpan QueuePauseResumePollInterval = TimeSpan.FromMilliseconds(250);
/// <summary>
/// Re-check cadence while a host-level provider-transient pause holds
/// dispatch. Fifteen seconds keeps the hold responsive to the pause's
/// self-expiry (default two minutes) without spinning the loop.
/// </summary>
private static readonly TimeSpan HostTransientPauseRecheckDelay = TimeSpan.FromSeconds(15);
private enum SpawnPacingWaitResult
{
Completed,
QueuePaused,
DispatchPaused,
Cancelled,
}
// Per-project semaphores: serialise budget check + StartedAt write to prevent
// TOCTOU races where multiple concurrent workers all pass the budget check before
// any of them has committed StartedAt to the database.
private readonly ConcurrentDictionary<string, SemaphoreSlim> _budgetLocks = new();
// Clock for the deferral-requeue timer, the heartbeat PeriodicTimer, the
// dispatch-wake retry backoff, the rolling budget-window cutoff, and the
// worker-lifecycle UtcNow stamps (StartedAt / progress-clock / recovery /
// webhook suggestedRetryAt). Defaults to TimeProvider.System so production
// behavior is byte-for-byte unchanged and DI/Program.cs need not be touched;
// tests inject a FakeTimeProvider and drive these waits with Advance()
// instead of starving on a real Task.Delay under CPU load.
//
// Deliberately NOT used by the spawn-pacing wait (WaitForSpawnPacingAsync)
// or the queue-pause poll (WaitIfPausedAsync): those are real-time polling
// loops whose whole contract is prompt wall-clock pause-detection latency
// (asserted by SpawnPacingDelay_BreaksPromptlyOnQueuePauseDuringWait). To
// avoid a mixed-clock trap the spawn-pacing UtcNow reads that feed that
// loop also stay on the system clock.
private readonly TimeProvider _time;
private readonly BackgroundServiceFailureTracker? _failureTracker;
// Consecutive dispatch-pickup attempts that failed to acquire the SQLite
// write gate. Reset on every successful pickup scan; reaching
// MaxConsecutiveDispatchGateTimeoutsBeforeEscalation escalates fatally.
private int _consecutiveDispatchGateTimeouts;
public OrchestratorService(
ITaskQueue queue,
IWorkItemStore store,
IPipelineRunner pipeline,
CancellationRegistry cancellations,
OrchestratorOptions opts,
ILogger<OrchestratorService> log,
AgentClassRouter? router = null,
IProjectRepository? projects = null,
IQueueController? queueController = null,
IWebhookDispatcher? webhooks = null,
IWorkerRegistry? workerRegistry = null,
DeadWorkerOptions? deadWorkerOpts = null,
DeadWorkerReaper? reaper = null,
ReleaseService? releaseService = null,
AgentConcurrencyOptions? agentConcurrency = null,
AgentConcurrencySnapshot? agentConcurrencySnapshot = null,
IBaselineImageResolver? baselineResolver = null,
OrchestratorProgressClock? progressClock = null,
QuotaRouterOptions? quotaRouterOptions = null,
BudgetDeferralRecheckSnapshot? budgetDeferralRecheck = null,
IStartupRecoveryInputBarrier? startupRecoveryBarrier = null,
IStartupInitialRecoverySink? startupRecoveryCompletion = null,
IAgentDispatchAvailability? dispatchAvailability = null,
IKnobRegistry? knobRegistry = null,
IQuotaRetryDispatchPromoter? quotaRetryDispatchPromoter = null,
IQuotaRetryAdmissionRouter? quotaRetryAdmissionRouter = null,
TimeProvider? timeProvider = null,
Func<long>? activeSandboxCountProvider = null,
BackgroundServiceFailureTracker? failureTracker = null,
WorkItemRepoReaper? repoReaper = null,
QuotaReservationLedger? reservationLedger = null,
IWorkItemCostStore? costStore = null,
AgentBurnEstimatorOptions? burnEstimatorOptions = null,
DispatchItemLivenessTracker? dispatchLiveness = null,
// Host-level provider-transient correlation gate: while a tripped
// signature pause is active the dispatch loop holds new spawns
// briefly instead of dispatching into a host/network blip. Optional;
// null disables the gate. Production DI shares the process-wide
// instance the PipelineRunner observes into.
ProviderTransientCorrelationTracker? hostTransientPause = null,
// Project-level base-broken hold: while a condition is active for a
// project, build-dependent candidates are skipped at pickup (the
// filed fix item is exempt). Optional; null disables the gate.
BaseBrokenConditionTracker? baseBrokenConditions = null)
{
_queue = queue;
_store = store;
_pipeline = pipeline;
_cancellations = cancellations;
_opts = opts;
_log = log;
_router = router;
_quotaRetryAdmissionRouter = quotaRetryAdmissionRouter ?? router;
_projects = projects;
_queueController = queueController;
_dispatchAvailability = dispatchAvailability;
_quotaRetryDispatchPromoter = quotaRetryDispatchPromoter;
_webhooks = webhooks;
_workerRegistry = workerRegistry;
_deadWorkerOpts = deadWorkerOpts;
_reaper = reaper;
_startupRecoveryBarrier = startupRecoveryBarrier;
_startupRecoveryCompletion = startupRecoveryCompletion;
_releaseService = releaseService;
_knobRegistry = knobRegistry;
_baselineResolver = baselineResolver ?? NullBaselineImageResolver.Instance;
_progressClock = progressClock ?? new OrchestratorProgressClock();
_reaper?.AttachWorkerPoolSlotReleaser(this);
_quotaRouterOptions = quotaRouterOptions;
_budgetDeferralRecheck = budgetDeferralRecheck;
_reservationLedger = reservationLedger;
_costStore = costStore;
_burnEstimatorOptions = burnEstimatorOptions;
_time = timeProvider ?? TimeProvider.System;
_dispatchLiveness = dispatchLiveness;
_hostTransientPause = hostTransientPause;
_baseBrokenConditions = baseBrokenConditions;
if (_dispatchLiveness is not null)
_dispatchLiveness.IsDeferredProvider = id => _deferredItems.Contains(id);
_activeSandboxCountProvider = activeSandboxCountProvider ?? (static () => SandboxLiveCounter.Active);
_repoReaper = repoReaper;
_repoReaper?.RegisterActiveItemCheck(id => _activeItems.ContainsKey(id));
// Prefer the shared snapshot when DI provides one (production path —
// PipelineRunner reads from the same instance, so hot-reload swaps
// here are visible there). Test fixtures that pass only the legacy
// options-shaped parameter get a fresh, unshared snapshot.
_concurrencySnapshot = agentConcurrencySnapshot
?? new AgentConcurrencySnapshot(agentConcurrency ?? new AgentConcurrencyOptions());
// Reject MaxConcurrent <= 0 loudly at startup. The same call runs again
// on every hot-reload via ApplyAgentConcurrencyReload — keeping the
// semantics identical between cold-start and config-edit paths.
AgentConcurrencyOptions.ValidateAndThrow(_concurrencySnapshot.Current);
_failureTracker = failureTracker;
_concurrencyGate = new ResizableConcurrencyGate(opts.MaxConcurrentWorkers);
_minSpawnIntervalTicks = opts.MinSpawnInterval.Ticks;
_preferInFlightOverFresh = opts.PreferInFlightOverFresh ? 1 : 0;
LogResolvedAgentCaps(_concurrencySnapshot.Current, reason: "startup");
}
/// <summary>
/// Replaces the per-agent concurrency cap dictionary with <paramref name="next"/>.
/// Called by the hot-reload coordinator when <c>CodeyBox:AgentConcurrency</c>
/// changes on disk. The swap is atomic against in-progress reservation
/// reads; in-flight items already past the gate are unaffected (caps are
/// only consulted at dispatch time).
/// </summary>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="next"/> contains a <c>MaxConcurrent <= 0</c>
/// entry. The hot-reload coordinator catches and surfaces this so the prior
/// config remains in effect rather than the dangerously-permissive default.
/// </exception>
public void ApplyAgentConcurrencyReload(AgentConcurrencyOptions next)
{
ArgumentNullException.ThrowIfNull(next);
AgentConcurrencyOptions.ValidateAndThrow(next);
var previous = _concurrencySnapshot.Current;
_concurrencySnapshot.Replace(next);
LogResolvedAgentCaps(next, reason: "hot-reload");
// Raised/removed caps (work or audit sub-cap) may admit queued session
// waiters — a resumed work slot or a blocked auditor session that fit
// under the new ceiling. Tightened caps need no wake: in-flight slots
// keep their permits and converge down on release.
DrainAllSessionSlotWaiters();
WakeCapDeferredItemsOnRelaxedCaps(previous, next);
}
/// <summary>
/// A cap that was raised (or removed — which relaxes the agent to
/// uncapped) may already have headroom for items deferred against it:
/// the freed capacity did not come from a slot release, so no
/// <see cref="WakeAgentCapWaitersForRouteRelease"/> signal exists and the
/// deferred item would otherwise sleep out its whole cap-retry interval.
/// Wake the waiters whose governing cap keys relaxed so the raised cap
/// takes effect at the next dispatch decision, then kick the dispatcher
/// so the scan runs immediately — the same contract
/// <see cref="ApplyWorkerPoolReload"/>'s grow path provides for the
/// global ceiling. Tightened or newly-added caps need no wake: they only
/// gate future reservations, which the next pickup already reads from
/// the swapped snapshot.
/// </summary>
private void WakeCapDeferredItemsOnRelaxedCaps(AgentConcurrencyOptions before, AgentConcurrencyOptions after)
{
var relaxed = RelaxedCapKeys(before, after);
if (relaxed.Count == 0)
return;
var woken = _deferredItems.WakeAgentCapWaitersForCapKeys(relaxed);
if (woken == 0)
return;
_log.LogInformation(
"AgentConcurrency reload relaxed cap(s) [{Keys}]: woke {Count} cap-deferred work item(s) back into dispatch ordering",
string.Join(", ", relaxed.OrderBy(static k => k, StringComparer.OrdinalIgnoreCase)), woken);
KickDispatchWakeUnobserved("Agent-cap reload relax");
}
/// <summary>
/// Member keys whose effective cap grew between the two snapshots.
/// A missing entry means uncapped, which reads as unbounded headroom —
/// so removing an entry relaxes it and adding one does not.
/// </summary>
private static HashSet<string> RelaxedCapKeys(AgentConcurrencyOptions before, AgentConcurrencyOptions after)
{
var relaxed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var key in before.Members.Keys.Concat(after.Members.Keys))
{
if (CapOrUncapped(after, key) > CapOrUncapped(before, key))
relaxed.Add(key);
}
return relaxed;
static int CapOrUncapped(AgentConcurrencyOptions opts, string key) =>
opts.Members.TryGetValue(key, out var entry) && entry is { MaxConcurrent: > 0 }
? entry.MaxConcurrent
: int.MaxValue;
}
/// <summary>
/// Hot-reloads the global worker-pool concurrency ceiling
/// (<c>CodeyBox:WorkerPool:MaxConcurrentWorkers</c>). Mirrors the existing
/// <see cref="ApplyAgentConcurrencyReload"/> path:
/// <list type="bullet">
/// <item>Reject <c><= 0</c> with the same semantics as the cold-start
/// validation in <see cref="OrchestratorOptionsFactory.Build(int?, WorkerPoolOptions, ILogger)"/>;
/// the coordinator catches and leaves the prior value in effect.</item>
/// <item>Grow admits queued waiters immediately; shrink converges down as
/// in-flight items finish naturally and is never allowed to abort
/// already-running work.</item>
/// <item>Emits an old→new log line and updates the resolved-caps log so
/// operators can confirm the new global ceiling. The <c>/concurrency</c>
/// endpoint reads the same live value via <see cref="GetConcurrencyState"/>.</item>
/// </list>
/// <para>
/// Idempotent: a reload with the same value is a no-op (no log, no
/// resize). The sibling WorkerPool knobs ride alongside through
/// <see cref="SandboxAdmissionControlledProvider.ApplyMaxConcurrentSandboxesReload"/>
/// and <see cref="ApplyMinSpawnIntervalReload"/>; see
/// <see cref="WorkerPoolHotReloadPolicy"/> for the full hot-reloadable set.
/// </para>
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="newMaxConcurrentWorkers"/> is < 1.
/// </exception>
public void ApplyWorkerPoolReload(int newMaxConcurrentWorkers)
{
if (newMaxConcurrentWorkers < 1)
throw new ArgumentOutOfRangeException(
nameof(newMaxConcurrentWorkers),
newMaxConcurrentWorkers,
"CodeyBox:WorkerPool:MaxConcurrentWorkers must be >= 1");
var result = _concurrencyGate.Resize(newMaxConcurrentWorkers);
if (result.OldTarget == result.NewTarget)
return;
_log.LogInformation(
"Hot-reloaded WorkerPool:MaxConcurrentWorkers: {OldValue} → {NewValue} (in-flight={InFlight})",
result.OldTarget,
result.NewTarget,
result.InFlight);
LogResolvedAgentCaps(_concurrencySnapshot.Current, reason: "worker-pool-reload");
// Grow path: the gate's DrainWaiters only wakes callers parked on
// _concurrencyGate.WaitAsync. In the typical steady state the
// dispatcher has filled the inner loop (TryEnter returned false),
// exited it, and is parked back on DequeueDispatchSignalAsync — NOT
// on the gate. Without an explicit kick, the new capacity wouldn't
// be used until the next slot-release fans wakes out. Enqueue one
// wake per new slot so the dispatcher exits its sleep on the kick
// channel and re-evaluates against the freshly-grown ceiling. Each
// wake is a "rescan the store" signal; surplus wakes when nothing
// is queued cost only a no-op dispatcher pass.
if (result.NewTarget > result.OldTarget)
{
var delta = result.NewTarget - result.OldTarget;
for (var i = 0; i < delta; i++)
KickDispatchWakeUnobserved("Worker-pool slot growth");
}
}
/// <summary>
/// Live spawn-pacing floor (<c>CodeyBox:WorkerPool:MinSpawnInterval</c>).
/// Seeded from <see cref="OrchestratorOptions.MinSpawnInterval"/> at
/// startup; the dispatch loop reads this value on every spawn so
/// <see cref="ApplyMinSpawnIntervalReload"/> takes effect without restart.
/// </summary>
public TimeSpan MinSpawnInterval => new(Volatile.Read(ref _minSpawnIntervalTicks));
/// <summary>
/// Hot-reloads the spawn-pacing floor
/// (<c>CodeyBox:WorkerPool:MinSpawnInterval</c>). The dispatch loop
/// snapshots <see cref="MinSpawnInterval"/> per spawn, so the new floor
/// applies to the next spawn; a spawn already waiting keeps the bound it
/// started with. Rejects negatives and values >= 1 hour with the same
/// semantics as the cold-start validation in
/// <see cref="OrchestratorOptionsFactory.Build(int?, WorkerPoolOptions, ILogger)"/>;
/// the coordinator catches and leaves the prior value in effect.
/// <para>
/// Idempotent: a reload with the same value is a no-op (no log).
/// </para>
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="newMinSpawnInterval"/> is negative or
/// >= 1 hour.
/// </exception>
public void ApplyMinSpawnIntervalReload(TimeSpan newMinSpawnInterval)
{
if (newMinSpawnInterval < TimeSpan.Zero)
throw new ArgumentOutOfRangeException(
nameof(newMinSpawnInterval),
newMinSpawnInterval,
"CodeyBox:WorkerPool:MinSpawnInterval must be >= 0");
if (newMinSpawnInterval >= TimeSpan.FromHours(1))
throw new ArgumentOutOfRangeException(
nameof(newMinSpawnInterval),
newMinSpawnInterval,
"CodeyBox:WorkerPool:MinSpawnInterval must be < 1 hour (values >= 1h are almost certainly a configuration error)");
var newTicks = newMinSpawnInterval.Ticks;
var oldTicks = Volatile.Read(ref _minSpawnIntervalTicks);
if (oldTicks == newTicks)
return;
Volatile.Write(ref _minSpawnIntervalTicks, newTicks);
_log.LogInformation(
"Hot-reloaded WorkerPool:MinSpawnInterval: {OldValue} → {NewValue}",
new TimeSpan(oldTicks),
newMinSpawnInterval);
}
/// <summary>
/// Live "in-flight before fresh" ordering preference
/// (<c>CodeyBox:WorkerPool:PreferInFlightOverFresh</c>). When on, pickup
/// ordering ranks past-work-phase items ahead of equal-priority fresh
/// starts, tie-broken by queue position. Seeded from
/// <see cref="OrchestratorOptions.PreferInFlightOverFresh"/> at startup;
/// <see cref="ApplyPreferInFlightOverFreshReload"/> takes effect on the
/// next pickup without restart.
/// </summary>
public bool PreferInFlightOverFresh => Volatile.Read(ref _preferInFlightOverFresh) != 0;
/// <summary>
/// The candidate ordering pickup queries currently run with — the live
/// mapping of <see cref="PreferInFlightOverFresh"/> onto
/// <see cref="DispatchCandidateOrdering"/>. Read once per candidate
/// enumeration so a hot-reload mid-scan cannot mix the two orderings
/// inside one ranked set.
/// </summary>
internal DispatchCandidateOrdering CurrentDispatchOrdering =>
PreferInFlightOverFresh
? DispatchCandidateOrdering.InFlightBeforeFresh
: DispatchCandidateOrdering.FinishingThenPriority;
/// <summary>
/// Hot-reloads the "in-flight before fresh" ordering preference
/// (<c>CodeyBox:WorkerPool:PreferInFlightOverFresh</c>). Turning it off
/// restores the legacy candidate ordering — finishing bucket, then
/// priority, then <c>created_at</c> — while cap-deferred items keep
/// waking on matching slot releases and rejoining ordering (that
/// mechanism is unconditional). Applies to the next pickup.
/// <para>Idempotent: a reload with the same value is a no-op (no log).</para>
/// </summary>
public void ApplyPreferInFlightOverFreshReload(bool newPreferInFlightOverFresh)
{
var next = newPreferInFlightOverFresh ? 1 : 0;
if (Interlocked.Exchange(ref _preferInFlightOverFresh, next) == next)
return;
_log.LogInformation(
"Hot-reloaded WorkerPool:PreferInFlightOverFresh: {NewValue}",
newPreferInFlightOverFresh);
}
/// <summary>
/// Emits the effective per-agent caps to the log so operators can confirm
/// (a) what the config-binder actually produced at startup, and (b) what a
/// hot-reload landed. Agents with no entry are listed as "unlimited" so
/// the line includes every agent that has work routed to it in this pool.
/// </summary>
private void LogResolvedAgentCaps(AgentConcurrencyOptions opts, string reason)
{
// Capped agents first (sorted for stable log output), so the line reads
// top-down by tightest constraint.
var rendered = opts.Members
.Where(kv => kv.Value is { MaxConcurrent: > 0 })
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
.Select(kv => kv.Value.MaxConcurrentAuditSessions is { } auditCap
? $"{kv.Key}={kv.Value.MaxConcurrent}(audit≤{auditCap})"
: $"{kv.Key}={kv.Value.MaxConcurrent}")
.ToList();
var summary = rendered.Count == 0 ? "<none>" : string.Join(", ", rendered);
_log.LogInformation(
"AgentConcurrency caps resolved ({Reason}): {Caps} (agents not listed are uncapped within global pool of {GlobalCap})",
reason, summary, _concurrencyGate.CurrentTarget);
}
/// <inheritdoc />
public int CurrentlyRunningTotal => Volatile.Read(ref _currentlyRunning);
/// <inheritdoc />
public int MaxConcurrent => _concurrencyGate.CurrentTarget;
/// <summary>
/// Snapshot of concurrency state for the <c>/concurrency</c> endpoint:
/// global cap, configured per-agent caps, and live per-agent in-flight counts.
/// </summary>
public ConcurrencyStateSnapshot GetConcurrencyState()
{
var opts = _concurrencySnapshot.Current;
var caps = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var auditCaps = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var kv in opts.Members)
{
// Defence-in-depth: validation rejects MaxConcurrent <= 0 entries
// at load, so the guard only fires when a test constructed an
// options instance directly without going through the validator.
if (kv.Value is { MaxConcurrent: > 0 })
{
caps[kv.Key] = kv.Value.MaxConcurrent;
auditCaps[kv.Key] = kv.Value.MaxConcurrentAuditSessions ?? kv.Value.MaxConcurrent;
}
}
var (runningWork, runningAudit) = SnapshotRouteSessions();
var running = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var kv in runningWork)
running[kv.Key] = kv.Value;
foreach (var kv in runningAudit)
running[kv.Key] = running.TryGetValue(kv.Key, out var w) ? w + kv.Value : kv.Value;
return new ConcurrencyStateSnapshot(
GlobalMaxConcurrent: _concurrencyGate.CurrentTarget,
CurrentlyRunningTotal: Volatile.Read(ref _currentlyRunning),
PerAgentCaps: caps,
CurrentlyRunningPerAgent: running,
CurrentlyRunningWorkPerAgent: runningWork,
CurrentlyRunningAuditPerAgent: runningAudit,
PerAgentAuditCaps: auditCaps);
}
/// <summary>
/// Per-agent release signal for cap-deferred items: a slot freed on
/// <paramref name="routeKey"/>, so items deferred against that route's cap
/// rejoin the ranked pickup set immediately instead of sleeping out the
/// rest of their cap-retry timer. Without this, a deferred in-flight item
/// (e.g. WorkComplete waiting on audit) loses the freed slot to any fresh
/// Queued item that happens to be evaluated first, regardless of priority
/// or queue position — deferred work then starves behind new starts.
///
/// <para>
/// Each cleared item goes through the same lease-checked removal the
/// requeue timer uses (<see cref="DeferredItemRegistry.WakeAgentCapWaiters"/>),
/// so the still sleeping timer becomes a no-op: the item is woken exactly
/// once. The timer is kept (not cancelled) as the backstop for releases
/// that never arrive — e.g. a cap raised by hot-reload, or an orphaned
/// worker slot reclaimed without a route release. Quota-only, budget,
/// pause, and no-progress deferrals are untouched and still recheck on
/// their own interval.
/// </para>
/// </summary>
private void WakeAgentCapWaitersForRouteRelease(string routeKey)
{
var woken = _deferredItems.WakeAgentCapWaiters(routeKey);
if (woken == 0)
return;
_log.LogInformation(
"Agent slot released on route {RouteKey}: woke {Count} cap-deferred work item(s) back into dispatch ordering",
routeKey, woken);
KickDispatchWakeUnobserved("Agent-cap slot-release");
}
/// <summary>
/// Enqueues a dispatcher wake without awaiting it, observing any fault so
/// a failed channel write surfaces as a debug log instead of an
/// unobserved task exception bubbling up to
/// TaskScheduler.UnobservedTaskException (which some deployments promote
/// to a fatal AppDomain.Unhandled). Used from synchronous paths —
/// shutdown pause, slot-growth fan-out, cap-release wake — where a
/// faulted kick must not escape into the caller.
/// </summary>
private void KickDispatchWakeUnobserved(string context)
{
try
{
var kickTask = _queue.EnqueueDispatchWakeAsync(CancellationToken.None);
if (!kickTask.IsCompletedSuccessfully)
{
kickTask.AsTask().ContinueWith(
t => _log.LogDebug(t.Exception, "{Context} wake kick faulted", context),
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously);
}
}
catch (Exception ex)
{
_log.LogDebug(ex, "{Context} wake kick threw synchronously; queue likely already shutting down", context);
}
}
/// <summary>
/// Ends one quota escrow: swaps the reservation estimate for this run's
/// observed usage (reconcile) when the run produced extracted token usage,
/// retains the estimate when the run produced only unmeasured
/// (elapsed-fallback) cost rows, or drops it when nothing ran at all.
/// Only cost rows started at or after <paramref name="runStartedAt"/> count
/// toward this dispatch — earlier rows belong to prior runs. Best-effort:
/// every failure path still releases the lease so accounting can never
/// wedge a worker exit or pin headroom.
/// </summary>
private async Task CompleteQuotaReservationAsync(
WorkItemId id,
QuotaReservationLease lease,
DateTimeOffset runStartedAt,
CancellationToken ct)
{
var ledger = _reservationLedger;
if (ledger is null) return;
// The reconcile read can throw OperationCanceledException (the cost
// store honors cancellation); the escrow must still be settled, so
// the Complete call lives in the finally. Cancellation here only
// means "no usable observation" — the estimate is released outright.
// A run with cost rows but no extracted token usage really consumed
// quota, so the estimate is retained as the settled cost rather than
// released as if the run were free. Best-effort throughout:
// accounting must never fail a worker exit.
double? observed = null;
var hasObservedUsage = false;
var hasRunRows = false;
try
{
try
{
if (_costStore is not null)
{
var rows = await _costStore.GetByWorkItemAsync(id.ToString(), ct);
var runRows = rows.Where(r => r.StartedAt >= runStartedAt).ToList();
hasRunRows = runRows.Count > 0;
hasObservedUsage = runRows.Any(r => r.HasExtractedTokenUsage);
if (hasObservedUsage)
{
var summary = WorkItemUsageAggregator.Summarise(runRows);
var budget = 0L;
_burnEstimatorOptions?.WindowTokenBudget.TryGetValue(lease.Agent.Value, out budget);
observed = QuotaReservationLedger.ToObservedPct(summary?.Total, budget);
}
}
}
catch (Exception ex)
{
_log.LogDebug(ex, "Quota escrow reconcile for {Id} failed; releasing the estimate", id);
}
}
finally
{
try
{
// No cost rows (or no cost store / failed read) releases the
// estimate outright — nothing measurable ran. Rows without
// extracted usage retain the estimate; rows with extracted
// usage reconcile against the observed cost.
if (!hasRunRows)
ledger.Complete(lease, observed);
else
ledger.Complete(lease, observed, hasObservedUsage);
}
catch (Exception ex)
{
_log.LogDebug(ex, "Quota escrow release for {Id} failed", id);
}
}
}
/// <summary>Snapshot for the /workers/status endpoint.</summary>
public async Task<WorkerPoolStatus> GetStatusAsync(CancellationToken ct = default)
{
var ticks = Interlocked.Read(ref _lastSpawnAtTicks);
var queuedCount = await _store.CountByStateAsync(WorkItemState.Queued, ct);
return new(
_concurrencyGate.CurrentTarget,
Volatile.Read(ref _currentlyRunning),
queuedCount,
ticks == 0 ? null : new DateTimeOffset(ticks, TimeSpan.Zero),
GetWorkerSlotOccupancy());
}
/// <summary>
/// Snapshot of every worker slot the pool currently believes is occupied,
/// with the owning worker index and work item. Used for diagnosis (so an
/// occupancy leak is confirmable without inferring it from unmatched log
/// lines) and by the orphaned-slot reconciliation.
/// </summary>
public IReadOnlyList<WorkerSlotOccupancy> GetWorkerSlotOccupancy()
{
var snapshot = new List<WorkerSlotOccupancy>(_liveWorkerSlots.Count);
foreach (var lease in _liveWorkerSlots.Keys)
{
snapshot.Add(ToOccupancy(lease));
}
snapshot.Sort(static (a, b) => a.WorkerIndex.CompareTo(b.WorkerIndex));
return snapshot;
}
public async Task<IReadOnlyList<RefactorProjectGateStatus>> GetRefactorProjectGateStatusAsync(
CancellationToken ct = default)
{
var statuses = new Dictionary<string, RefactorProjectGateStatus>(StringComparer.Ordinal);
var activeClaims = (await CollectActiveRefactorDrainClaimsAsync(ct))
.ToDictionary(c => c.ProjectId.Value, c => c, StringComparer.Ordinal);
var allItems = new List<WorkItem>();
await foreach (var item in _store.ListAsync(ct))
allItems.Add(item);
foreach (var claim in activeClaims.Values)
{
var counts = await _store.CountInFlightSplitByRefactorAsync(
claim.ProjectId,
ct,
claim.RefactorWorkItemId);
statuses[claim.ProjectId.Value] = new RefactorProjectGateStatus(
claim.ProjectId,
"draining",
claim.RefactorWorkItemId,
counts.Refactor,
counts.Other,
claim.Reason);
}
foreach (var item in allItems)
{
if (item.JobType != JobType.Refactor || !WorkItemInFlight.IsInFlight(item))
continue;
var counts = await _store.CountInFlightSplitByRefactorAsync(item.ProjectId, ct);
statuses[item.ProjectId.Value] = new RefactorProjectGateStatus(
item.ProjectId,
"locked",
item.Id,
counts.Refactor,
counts.Other,
$"refactor exclusivity: refactor '{item.Id}' is in flight for project '{item.ProjectId.Value}'");
}
var statesById = WorkItemDependencies.BuildStateMap(allItems);
var priorFreshDispatchProjects = new HashSet<string>(StringComparer.Ordinal);
foreach (var item in allItems
.OrderBy(DispatchPhaseBucket)
.ThenByDescending(i => i.Priority)
.ThenBy(i => i.CreatedAt))
{
if (item.StartedAt is not null
|| WorkItemInFlight.IsExcludedState(item.State)
|| !WorkItemDependencies.AreSatisfied(item.DependsOn, statesById)
|| statuses.ContainsKey(item.ProjectId.Value))
{
continue;
}
if (item.JobType != JobType.Refactor)
{
priorFreshDispatchProjects.Add(item.ProjectId.Value);
continue;
}
if (!priorFreshDispatchProjects.Add(item.ProjectId.Value))
continue;
var counts = await _store.CountInFlightSplitByRefactorAsync(item.ProjectId, ct, item.Id);
if (counts.Refactor == 0 && counts.Other == 0)
continue;
var reason = activeClaims.TryGetValue(item.ProjectId.Value, out var claim)
? claim.Reason
: $"refactor drain: queued refactor '{item.Id}' is waiting for project '{item.ProjectId.Value}' to drain; fresh non-refactor starts are held";
statuses[item.ProjectId.Value] = new RefactorProjectGateStatus(
item.ProjectId,
"draining",
item.Id,
counts.Refactor,
counts.Other,
reason);
}
return statuses.Values
.OrderBy(s => s.ProjectId.Value, StringComparer.Ordinal)
.ToList();
}
public async Task<RefactorDispatchGateDecision> CheckRefactorDispatchGateAsync(
RefactorDispatchCandidate candidate,
CancellationToken ct = default)
{
var activeClaims = await CollectActiveRefactorDrainClaimsAsync(ct);
var projectClaim = activeClaims.FirstOrDefault(c => c.ProjectId == candidate.ProjectId);
if (candidate.StartedAt is not null)
{
var (startedCandidateProjectRefactors, startedCandidateOtherInFlight) =
await _store.CountInFlightSplitByRefactorAsync(candidate.ProjectId, ct, candidate.Id);
if (candidate.JobType == JobType.Refactor)
{
return startedCandidateProjectRefactors == 0
? RefactorDispatchGateDecision.Allow
: RefactorDispatchGateDecision.Block(
RefactorCandidateBlockedReason(
candidate,
startedCandidateProjectRefactors,
startedCandidateOtherInFlight));
}
return startedCandidateProjectRefactors == 0
? RefactorDispatchGateDecision.Allow
: RefactorDispatchGateDecision.Block(
$"refactor exclusivity: a refactor is in flight for project '{candidate.ProjectId.Value}'; non-refactor items wait until it completes");
}
if (candidate.JobType == JobType.Refactor)
{
if (projectClaim is not null && projectClaim.RefactorWorkItemId != candidate.Id)
{
return RefactorDispatchGateDecision.Block(