bosun 0.40.2 → 0.40.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -109,6 +109,8 @@ const MAX_NO_COMMIT_ATTEMPTS = 3; // Stop picking up a task after N consecutive
109
109
  const NO_COMMIT_COOLDOWN_BASE_MS = 15 * 60 * 1000; // 15 minutes base cooldown for no-commit
110
110
  const NO_COMMIT_MAX_COOLDOWN_MS = 2 * 60 * 60 * 1000;
111
111
  const CLAIM_CONFLICT_COMMENT_COOLDOWN_MS = 30 * 60 * 1000; // 30 minutes
112
+ const REPO_AREA_SLOW_MERGE_LATENCY_MS = 4 * 60 * 60 * 1000;
113
+ const REPO_AREA_VERY_SLOW_MERGE_LATENCY_MS = 8 * 60 * 60 * 1000;
112
114
  const FATAL_CLAIM_RENEW_ERRORS = new Set([
113
115
  "task_claimed_by_different_instance",
114
116
  "claim_token_mismatch",
@@ -244,6 +246,188 @@ function truncateUtf8Bytes(text, maxBytes = 12000) {
244
246
  return `${best}${suffix}`;
245
247
  }
246
248
 
249
+ function normalizeRepoAreaKey(value) {
250
+ return String(value || "").trim().toLowerCase();
251
+ }
252
+
253
+ function extractRepoAreasFromTaskLike(task) {
254
+ const values = [];
255
+ const pushValues = (input) => {
256
+ if (!input) return;
257
+ if (Array.isArray(input)) {
258
+ for (const entry of input) pushValues(entry);
259
+ return;
260
+ }
261
+ const normalized = normalizeRepoAreaKey(input);
262
+ if (!normalized) return;
263
+ values.push(normalized);
264
+ };
265
+
266
+ pushValues(task?.repo_areas);
267
+ pushValues(task?.repoAreas);
268
+ pushValues(task?.meta?.repo_areas);
269
+ pushValues(task?.meta?.repoAreas);
270
+ pushValues(task?.task?.repo_areas);
271
+ pushValues(task?.task?.repoAreas);
272
+ pushValues(task?.task?.meta?.repo_areas);
273
+ pushValues(task?.task?.meta?.repoAreas);
274
+
275
+ return [...new Set(values)].slice(0, 10);
276
+ }
277
+
278
+ function normalizeRepoAreaTelemetryEntry(raw = {}) {
279
+ const recentOutcomes = Array.isArray(raw?.recentOutcomes)
280
+ ? raw.recentOutcomes
281
+ .map((value) => Number(value))
282
+ .filter((value) => value === 0 || value === 1)
283
+ .slice(-20)
284
+ : [];
285
+ const mergeLatencySamples = Array.isArray(raw?.mergeLatencySamples)
286
+ ? raw.mergeLatencySamples
287
+ .map((value) => Number(value))
288
+ .filter((value) => Number.isFinite(value) && value >= 0)
289
+ .slice(-12)
290
+ : [];
291
+ return {
292
+ conflictCount: Math.max(0, Math.trunc(Number(raw?.conflictCount || 0))),
293
+ totalWaitMs: Math.max(0, Math.trunc(Number(raw?.totalWaitMs || 0))),
294
+ lastWaitMs: Math.max(0, Math.trunc(Number(raw?.lastWaitMs || 0))),
295
+ maxWaitMs: Math.max(0, Math.trunc(Number(raw?.maxWaitMs || 0))),
296
+ lastBlockedAt: Math.max(0, Math.trunc(Number(raw?.lastBlockedAt || 0))),
297
+ lastOutcomeAt: Math.max(0, Math.trunc(Number(raw?.lastOutcomeAt || 0))),
298
+ recentOutcomes,
299
+ mergeLatencySamples,
300
+ };
301
+ }
302
+
303
+ function createEmptyRepoAreaTelemetryEntry() {
304
+ return normalizeRepoAreaTelemetryEntry();
305
+ }
306
+
307
+ function averageNumbers(values = []) {
308
+ if (!Array.isArray(values) || values.length === 0) return 0;
309
+ const total = values.reduce((sum, value) => sum + Number(value || 0), 0);
310
+ return total / values.length;
311
+ }
312
+
313
+ function buildRepoAreaAdaptiveSignals(entry, baseLimit) {
314
+ const normalizedBase = Number(baseLimit || 0);
315
+ const outcomes = Array.isArray(entry?.recentOutcomes) ? entry.recentOutcomes : [];
316
+ const failures = outcomes.reduce((sum, value) => sum + (Number(value) > 0 ? 1 : 0), 0);
317
+ const samples = outcomes.length;
318
+ const failureRate = samples > 0 ? failures / samples : 0;
319
+ const mergeLatencyAvgMs = averageNumbers(entry?.mergeLatencySamples || []);
320
+ const adaptiveReasons = [];
321
+ if (samples >= 4 && failureRate >= 0.5) adaptiveReasons.push("failure_rate");
322
+ if (
323
+ (entry?.mergeLatencySamples?.length || 0) >= 3 &&
324
+ mergeLatencyAvgMs >= REPO_AREA_SLOW_MERGE_LATENCY_MS
325
+ ) {
326
+ adaptiveReasons.push("merge_latency");
327
+ }
328
+ const adaptivePenalty =
329
+ normalizedBase > 1 && adaptiveReasons.length > 0 ? 1 : 0;
330
+ const effectiveLimit =
331
+ normalizedBase > 0
332
+ ? Math.max(1, normalizedBase - adaptivePenalty)
333
+ : 0;
334
+ return {
335
+ recentSamples: samples,
336
+ failureRate,
337
+ mergeLatencyAvgMs,
338
+ adaptivePenalty,
339
+ adaptiveReasons,
340
+ effectiveLimit,
341
+ severeMergeLatency:
342
+ (entry?.mergeLatencySamples?.length || 0) >= 3 &&
343
+ mergeLatencyAvgMs >= REPO_AREA_VERY_SLOW_MERGE_LATENCY_MS,
344
+ };
345
+ }
346
+
347
+ function buildRepoAreaWaitingCounts(blockedTasks = new Map()) {
348
+ const waitingCounts = new Map();
349
+ for (const blocked of blockedTasks.values()) {
350
+ const areas = Array.isArray(blocked?.areas) ? blocked.areas : [];
351
+ for (const area of areas) {
352
+ const areaKey = normalizeRepoAreaKey(area);
353
+ if (!areaKey) continue;
354
+ waitingCounts.set(areaKey, (waitingCounts.get(areaKey) || 0) + 1);
355
+ }
356
+ }
357
+ return waitingCounts;
358
+ }
359
+
360
+ function buildRepoAreaLockStatusSnapshot({
361
+ repoAreaParallelLimit = 0,
362
+ activeSlots = [],
363
+ telemetryByArea = new Map(),
364
+ blockedTasks = new Map(),
365
+ dispatchCycles = 0,
366
+ totalConflicts = 0,
367
+ } = {}) {
368
+ const baseLimit = Math.max(0, Math.trunc(Number(repoAreaParallelLimit || 0)));
369
+ const activeCounts = new Map();
370
+ for (const slot of activeSlots) {
371
+ const areas = extractRepoAreasFromTaskLike(slot);
372
+ for (const area of areas) {
373
+ activeCounts.set(area, (activeCounts.get(area) || 0) + 1);
374
+ }
375
+ }
376
+
377
+ const waitingCounts = buildRepoAreaWaitingCounts(blockedTasks);
378
+ const allAreas = new Set([
379
+ ...activeCounts.keys(),
380
+ ...waitingCounts.keys(),
381
+ ...telemetryByArea.keys(),
382
+ ]);
383
+
384
+ const areas = {};
385
+ const hotAreas = [];
386
+ for (const area of Array.from(allAreas).sort()) {
387
+ const telemetry = normalizeRepoAreaTelemetryEntry(telemetryByArea.get(area));
388
+ const adaptive = buildRepoAreaAdaptiveSignals(telemetry, baseLimit);
389
+ const snapshot = {
390
+ baseLimit,
391
+ effectiveLimit: adaptive.effectiveLimit,
392
+ active: activeCounts.get(area) || 0,
393
+ waiting: waitingCounts.get(area) || 0,
394
+ conflictCount: telemetry.conflictCount,
395
+ totalWaitMs: telemetry.totalWaitMs,
396
+ lastWaitMs: telemetry.lastWaitMs,
397
+ maxWaitMs: telemetry.maxWaitMs,
398
+ failureRate: adaptive.failureRate,
399
+ mergeLatencyAvgMs: adaptive.mergeLatencyAvgMs,
400
+ recentSamples: adaptive.recentSamples,
401
+ adaptivePenalty: adaptive.adaptivePenalty,
402
+ adaptiveReasons: adaptive.adaptiveReasons,
403
+ severeMergeLatency: adaptive.severeMergeLatency,
404
+ lastBlockedAt: telemetry.lastBlockedAt,
405
+ lastOutcomeAt: telemetry.lastOutcomeAt,
406
+ };
407
+ areas[area] = snapshot;
408
+ hotAreas.push({ area, ...snapshot });
409
+ }
410
+
411
+ hotAreas.sort((a, b) => {
412
+ if (b.waiting !== a.waiting) return b.waiting - a.waiting;
413
+ if (b.adaptivePenalty !== a.adaptivePenalty) {
414
+ return b.adaptivePenalty - a.adaptivePenalty;
415
+ }
416
+ if (b.conflictCount !== a.conflictCount) return b.conflictCount - a.conflictCount;
417
+ return a.area.localeCompare(b.area);
418
+ });
419
+
420
+ return {
421
+ enabled: baseLimit > 0,
422
+ baseLimit,
423
+ dispatchCycles: Math.max(0, Math.trunc(Number(dispatchCycles || 0))),
424
+ totalConflicts: Math.max(0, Math.trunc(Number(totalConflicts || 0))),
425
+ waitingTasks: blockedTasks.size,
426
+ areas,
427
+ hotAreas: hotAreas.slice(0, 5),
428
+ };
429
+ }
430
+
247
431
  function isBosunStateComment(text) {
248
432
  const raw = String(text || "").toLowerCase();
249
433
  if (!raw) return false;
@@ -2095,6 +2279,20 @@ class TaskExecutor {
2095
2279
  this._noCommitCounts = new Map();
2096
2280
  /** @type {Map<string, number>} taskId → skip-until timestamp */
2097
2281
  this._skipUntil = new Map();
2282
+ /** @type {Map<string, { conflicts: number, blockedDispatches: number, selectedDispatches: number, waitMsTotal: number, waitSamples: number, maxWaitMs: number, lastConflictAt: string|null, lastSelectedAt: string|null }>} */
2283
+ this._repoAreaLockMetrics = new Map();
2284
+ /** @type {Map<string, { taskId: string, area: string, startedAt: number }>} */
2285
+ this._repoAreaPendingWaits = new Map();
2286
+ this._repoAreaDispatchCycle = {
2287
+ cycle: 0,
2288
+ at: null,
2289
+ candidateCount: 0,
2290
+ remaining: 0,
2291
+ selectedCount: 0,
2292
+ blockedTasks: 0,
2293
+ blockedByArea: {},
2294
+ saturatedAreas: [],
2295
+ };
2098
2296
 
2099
2297
  // Track tasks that have already been completed with a PR (prevents re-dispatch loop)
2100
2298
  /** @type {Set<string>} taskId set */
@@ -2119,6 +2317,16 @@ class TaskExecutor {
2119
2317
  /** @type {Map<string, { taskId: string, taskTitle: string, branch: string, sdk: string, model: string, attempt: number, startedAt: number, agentInstanceId: number|null, status: string, updatedAt: number }>} */
2120
2318
  this._slotRuntimeState = new Map();
2121
2319
  this._nextAgentInstanceId = 1;
2320
+ /** @type {Map<string, ReturnType<typeof normalizeRepoAreaTelemetryEntry>>} */
2321
+ this._repoAreaTelemetry = new Map();
2322
+ /** @type {Map<string, { blockedAt: number, lastObservedWaitMs: number, areas: string[] }>} */
2323
+ this._repoAreaBlockedTasks = new Map();
2324
+ /** @type {Map<string, string[]>} */
2325
+ this._repoAreaTaskAreas = new Map();
2326
+ /** @type {Map<string, number>} */
2327
+ this._repoAreaTaskStartedAt = new Map();
2328
+ this._repoAreaDispatchCycles = 0;
2329
+ this._repoAreaConflictCount = 0;
2122
2330
 
2123
2331
  // Repo context cache (AGENTS.md, copilot-instructions.md)
2124
2332
  this._contextCache = null;
@@ -2251,6 +2459,57 @@ class TaskExecutor {
2251
2459
  this._nextAgentInstanceId = Math.floor(nextId);
2252
2460
  }
2253
2461
 
2462
+ this._repoAreaDispatchCycles = Math.max(
2463
+ 0,
2464
+ Math.trunc(Number(parsed?.repoAreaDispatchCycles || 0)),
2465
+ );
2466
+ this._repoAreaConflictCount = Math.max(
2467
+ 0,
2468
+ Math.trunc(Number(parsed?.repoAreaConflictCount || 0)),
2469
+ );
2470
+ this._repoAreaTelemetry.clear();
2471
+ for (const [area, raw] of Object.entries(parsed?.repoAreaTelemetry || {})) {
2472
+ const areaKey = normalizeRepoAreaKey(area);
2473
+ if (!areaKey) continue;
2474
+ this._repoAreaTelemetry.set(areaKey, normalizeRepoAreaTelemetryEntry(raw));
2475
+ }
2476
+ this._repoAreaBlockedTasks.clear();
2477
+ for (const [taskId, raw] of Object.entries(parsed?.repoAreaBlockedTasks || {})) {
2478
+ const key = normalizeTaskIdKey(taskId);
2479
+ if (!key) continue;
2480
+ const blockedAt = Math.max(0, Math.trunc(Number(raw?.blockedAt || 0)));
2481
+ const lastObservedWaitMs = Math.max(
2482
+ 0,
2483
+ Math.trunc(Number(raw?.lastObservedWaitMs || 0)),
2484
+ );
2485
+ const areas = Array.isArray(raw?.areas)
2486
+ ? raw.areas.map((area) => normalizeRepoAreaKey(area)).filter(Boolean)
2487
+ : [];
2488
+ if (!blockedAt || areas.length === 0) continue;
2489
+ this._repoAreaBlockedTasks.set(key, {
2490
+ blockedAt,
2491
+ lastObservedWaitMs,
2492
+ areas: [...new Set(areas)],
2493
+ });
2494
+ }
2495
+ this._repoAreaTaskAreas.clear();
2496
+ for (const [taskId, rawAreas] of Object.entries(parsed?.repoAreaTaskAreas || {})) {
2497
+ const key = normalizeTaskIdKey(taskId);
2498
+ if (!key) continue;
2499
+ const areas = Array.isArray(rawAreas)
2500
+ ? rawAreas.map((area) => normalizeRepoAreaKey(area)).filter(Boolean)
2501
+ : [];
2502
+ if (areas.length === 0) continue;
2503
+ this._repoAreaTaskAreas.set(key, [...new Set(areas)]);
2504
+ }
2505
+ this._repoAreaTaskStartedAt.clear();
2506
+ for (const [taskId, rawStartedAt] of Object.entries(parsed?.repoAreaTaskStartedAt || {})) {
2507
+ const key = normalizeTaskIdKey(taskId);
2508
+ const startedAt = Math.max(0, Math.trunc(Number(rawStartedAt || 0)));
2509
+ if (!key || !startedAt) continue;
2510
+ this._repoAreaTaskStartedAt.set(key, startedAt);
2511
+ }
2512
+
2254
2513
  const slots = parsed?.slots || {};
2255
2514
  let restored = 0;
2256
2515
  for (const [taskId, entry] of Object.entries(slots)) {
@@ -2325,6 +2584,12 @@ class TaskExecutor {
2325
2584
  pauseUntil: this._pauseUntil,
2326
2585
  pauseReason: this._pauseReason,
2327
2586
  nextAgentInstanceId: this._nextAgentInstanceId,
2587
+ repoAreaDispatchCycles: this._repoAreaDispatchCycles,
2588
+ repoAreaConflictCount: this._repoAreaConflictCount,
2589
+ repoAreaTelemetry: Object.fromEntries(this._repoAreaTelemetry),
2590
+ repoAreaBlockedTasks: Object.fromEntries(this._repoAreaBlockedTasks),
2591
+ repoAreaTaskAreas: Object.fromEntries(this._repoAreaTaskAreas),
2592
+ repoAreaTaskStartedAt: Object.fromEntries(this._repoAreaTaskStartedAt),
2328
2593
  slots,
2329
2594
  savedAt: new Date().toISOString(),
2330
2595
  },
@@ -2362,6 +2627,132 @@ class TaskExecutor {
2362
2627
  this._saveRuntimeState();
2363
2628
  }
2364
2629
 
2630
+ _ensureRepoAreaTelemetry(areaKey) {
2631
+ const normalized = normalizeRepoAreaKey(areaKey);
2632
+ if (!normalized) return null;
2633
+ let entry = this._repoAreaTelemetry.get(normalized);
2634
+ if (!entry) {
2635
+ entry = createEmptyRepoAreaTelemetryEntry();
2636
+ this._repoAreaTelemetry.set(normalized, entry);
2637
+ }
2638
+ return entry;
2639
+ }
2640
+
2641
+ _rememberTaskRepoAreas(task, now = Date.now()) {
2642
+ const taskId = normalizeTaskIdKey(task?.id || task?.taskId || task?.task_id);
2643
+ if (!taskId) return [];
2644
+ const areas = extractRepoAreasFromTaskLike(task);
2645
+ if (areas.length > 0) {
2646
+ this._repoAreaTaskAreas.set(taskId, areas);
2647
+ if (!this._repoAreaTaskStartedAt.has(taskId)) {
2648
+ this._repoAreaTaskStartedAt.set(taskId, now);
2649
+ }
2650
+ }
2651
+ return areas;
2652
+ }
2653
+
2654
+ _clearRepoAreaBlockedTask(taskId) {
2655
+ const key = normalizeTaskIdKey(taskId);
2656
+ if (!key) return;
2657
+ this._repoAreaBlockedTasks.delete(key);
2658
+ }
2659
+
2660
+ _recordRepoAreaConflict(task, blockedAreas, now = Date.now()) {
2661
+ const taskId = normalizeTaskIdKey(task?.id || task?.taskId || task?.task_id);
2662
+ if (!taskId || !Array.isArray(blockedAreas) || blockedAreas.length === 0) return;
2663
+ const normalizedAreas = [...new Set(blockedAreas.map((area) => normalizeRepoAreaKey(area)).filter(Boolean))];
2664
+ if (normalizedAreas.length === 0) return;
2665
+ const current = this._repoAreaBlockedTasks.get(taskId) || {
2666
+ blockedAt: now,
2667
+ lastObservedWaitMs: 0,
2668
+ areas: normalizedAreas,
2669
+ };
2670
+ current.areas = normalizedAreas;
2671
+ const waitMs = Math.max(0, now - current.blockedAt);
2672
+ const waitDelta = Math.max(0, waitMs - Number(current.lastObservedWaitMs || 0));
2673
+ current.lastObservedWaitMs = waitMs;
2674
+ this._repoAreaBlockedTasks.set(taskId, current);
2675
+ this._repoAreaConflictCount += 1;
2676
+ for (const areaKey of normalizedAreas) {
2677
+ const entry = this._ensureRepoAreaTelemetry(areaKey);
2678
+ if (!entry) continue;
2679
+ entry.conflictCount += 1;
2680
+ entry.totalWaitMs += waitDelta;
2681
+ entry.lastWaitMs = waitMs;
2682
+ entry.maxWaitMs = Math.max(entry.maxWaitMs, waitMs);
2683
+ entry.lastBlockedAt = now;
2684
+ }
2685
+ }
2686
+
2687
+ _getEffectiveRepoAreaLimit(areaKey) {
2688
+ const baseLimit = Number(this.repoAreaParallelLimit || 0);
2689
+ if (!Number.isFinite(baseLimit) || baseLimit <= 0) return 0;
2690
+ const entry = this._repoAreaTelemetry.get(normalizeRepoAreaKey(areaKey));
2691
+ return buildRepoAreaAdaptiveSignals(entry, baseLimit).effectiveLimit;
2692
+ }
2693
+
2694
+ noteRepoAreaOutcome(taskOrTaskId, outcome = {}) {
2695
+ const taskId = normalizeTaskIdKey(
2696
+ typeof taskOrTaskId === "string"
2697
+ ? taskOrTaskId
2698
+ : taskOrTaskId?.id || taskOrTaskId?.taskId || taskOrTaskId?.task_id,
2699
+ );
2700
+ if (!taskId) return false;
2701
+
2702
+ const areas =
2703
+ typeof taskOrTaskId === "string"
2704
+ ? this._repoAreaTaskAreas.get(taskId) || []
2705
+ : this._rememberTaskRepoAreas(taskOrTaskId);
2706
+ if (!Array.isArray(areas) || areas.length === 0) return false;
2707
+
2708
+ const now = Date.now();
2709
+ const status = normalizeRepoAreaKey(outcome.status);
2710
+ const isFailure =
2711
+ outcome.failed === true ||
2712
+ ["blocked", "failed", "error", "errored"].includes(status);
2713
+ const isSuccess =
2714
+ outcome.failed !== true &&
2715
+ ["done", "inreview", "complete", "completed", "merged", "success"].includes(status);
2716
+ let mergeLatencyMs = Number(outcome.mergeLatencyMs || 0);
2717
+ if ((!Number.isFinite(mergeLatencyMs) || mergeLatencyMs <= 0) && isSuccess) {
2718
+ const startedAt = Number(this._repoAreaTaskStartedAt.get(taskId) || 0);
2719
+ if (startedAt > 0) mergeLatencyMs = Math.max(0, now - startedAt);
2720
+ }
2721
+
2722
+ for (const areaKey of areas) {
2723
+ const entry = this._ensureRepoAreaTelemetry(areaKey);
2724
+ if (!entry) continue;
2725
+ if (isFailure || isSuccess) {
2726
+ entry.recentOutcomes.push(isFailure ? 1 : 0);
2727
+ entry.recentOutcomes = entry.recentOutcomes.slice(-20);
2728
+ entry.lastOutcomeAt = now;
2729
+ }
2730
+ if (Number.isFinite(mergeLatencyMs) && mergeLatencyMs > 0) {
2731
+ entry.mergeLatencySamples.push(Math.trunc(mergeLatencyMs));
2732
+ entry.mergeLatencySamples = entry.mergeLatencySamples.slice(-12);
2733
+ entry.lastOutcomeAt = now;
2734
+ }
2735
+ }
2736
+
2737
+ this._clearRepoAreaBlockedTask(taskId);
2738
+ if (isFailure || isSuccess) {
2739
+ this._repoAreaTaskStartedAt.delete(taskId);
2740
+ }
2741
+ this._saveRuntimeState();
2742
+ return true;
2743
+ }
2744
+
2745
+ _buildRepoAreaLockStatus() {
2746
+ return buildRepoAreaLockStatusSnapshot({
2747
+ repoAreaParallelLimit: this.repoAreaParallelLimit,
2748
+ activeSlots: Array.from(this._activeSlots.values()),
2749
+ telemetryByArea: this._repoAreaTelemetry,
2750
+ blockedTasks: this._repoAreaBlockedTasks,
2751
+ dispatchCycles: this._repoAreaDispatchCycles,
2752
+ totalConflicts: this._repoAreaConflictCount,
2753
+ });
2754
+ }
2755
+
2365
2756
  /**
2366
2757
  * Delete runtime state for a task slot after completion/failure reset.
2367
2758
  * @param {string} taskId
@@ -3182,6 +3573,7 @@ class TaskExecutor {
3182
3573
  maxRetries: this.maxRetries,
3183
3574
  projectId: this._resolvedProjectId || this.projectId || null,
3184
3575
  backlogReplenishment: { ...this._backlogReplenishment },
3576
+ repoAreaLocks: this._buildRepoAreaLockStatus(),
3185
3577
  projectRequirements: { ...this._projectRequirements },
3186
3578
  };
3187
3579
  }
@@ -3428,6 +3820,253 @@ class TaskExecutor {
3428
3820
  }
3429
3821
  return counts;
3430
3822
  }
3823
+
3824
+ _getRepoAreaLockMetric(area) {
3825
+ if (!area) return null;
3826
+ if (!this._repoAreaLockMetrics.has(area)) {
3827
+ this._repoAreaLockMetrics.set(area, {
3828
+ conflicts: 0,
3829
+ blockedDispatches: 0,
3830
+ selectedDispatches: 0,
3831
+ waitMsTotal: 0,
3832
+ waitSamples: 0,
3833
+ maxWaitMs: 0,
3834
+ lastConflictAt: null,
3835
+ lastSelectedAt: null,
3836
+ });
3837
+ }
3838
+ return this._repoAreaLockMetrics.get(area);
3839
+ }
3840
+
3841
+ _makeRepoAreaWaitKey(taskId, area) {
3842
+ const normalizedTaskId = normalizeTaskIdKey(taskId);
3843
+ if (!normalizedTaskId || !area) return "";
3844
+ return `${normalizedTaskId}::${area}`;
3845
+ }
3846
+
3847
+ _beginRepoAreaWait(taskId, area, now = Date.now()) {
3848
+ const key = this._makeRepoAreaWaitKey(taskId, area);
3849
+ if (!key || this._repoAreaPendingWaits.has(key)) return;
3850
+ this._repoAreaPendingWaits.set(key, {
3851
+ taskId: normalizeTaskIdKey(taskId),
3852
+ area,
3853
+ startedAt: now,
3854
+ });
3855
+ }
3856
+
3857
+ _finalizeRepoAreaWait(taskId, area, now = Date.now()) {
3858
+ const key = this._makeRepoAreaWaitKey(taskId, area);
3859
+ if (!key) return 0;
3860
+ const pending = this._repoAreaPendingWaits.get(key);
3861
+ if (!pending) return 0;
3862
+ this._repoAreaPendingWaits.delete(key);
3863
+ const durationMs = Math.max(0, now - Number(pending.startedAt || now));
3864
+ const metric = this._getRepoAreaLockMetric(area);
3865
+ if (metric && durationMs > 0) {
3866
+ metric.waitMsTotal += durationMs;
3867
+ metric.waitSamples += 1;
3868
+ metric.maxWaitMs = Math.max(metric.maxWaitMs, durationMs);
3869
+ }
3870
+ return durationMs;
3871
+ }
3872
+
3873
+ _pruneRepoAreaPendingWaits(candidates, now = Date.now()) {
3874
+ const candidateIds = new Set(
3875
+ (Array.isArray(candidates) ? candidates : [])
3876
+ .map((task) => normalizeTaskIdKey(task?.id || task?.task_id))
3877
+ .filter(Boolean),
3878
+ );
3879
+ for (const pending of Array.from(this._repoAreaPendingWaits.values())) {
3880
+ if (
3881
+ !candidateIds.has(pending.taskId) &&
3882
+ !this._activeSlots.has(pending.taskId)
3883
+ ) {
3884
+ this._finalizeRepoAreaWait(pending.taskId, pending.area, now);
3885
+ }
3886
+ }
3887
+ }
3888
+
3889
+ _clearRepoAreaWaitsForTask(task, now = Date.now()) {
3890
+ const taskId = normalizeTaskIdKey(task?.id || task?.task_id);
3891
+ if (!taskId) return;
3892
+ for (const area of this._extractTaskRepoAreas(task)) {
3893
+ this._finalizeRepoAreaWait(taskId, area, now);
3894
+ }
3895
+ }
3896
+
3897
+ _buildActiveRepoAreaSignals(now = Date.now()) {
3898
+ const signals = new Map();
3899
+ for (const slot of this._activeSlots.values()) {
3900
+ const areas = this._extractTaskRepoAreas(slot);
3901
+ if (areas.length === 0) continue;
3902
+ const startedAt = Number(slot?.startedAt || 0);
3903
+ const activeAgeMs = startedAt > 0 ? Math.max(0, now - startedAt) : 0;
3904
+ const mergeLatencyMs = Number(
3905
+ slot?.mergeLatencyMs || slot?.mergeLatency || 0,
3906
+ );
3907
+ const latencySampleMs = mergeLatencyMs > 0 ? mergeLatencyMs : activeAgeMs;
3908
+ const attempt = Number(slot?.attempt || 0);
3909
+ const status = String(slot?.status || "").trim().toLowerCase();
3910
+ for (const area of areas) {
3911
+ let signal = signals.get(area);
3912
+ if (!signal) {
3913
+ signal = {
3914
+ active: 0,
3915
+ retrying: 0,
3916
+ mergeLatencyMsTotal: 0,
3917
+ mergeLatencySamples: 0,
3918
+ maxMergeLatencyMs: 0,
3919
+ };
3920
+ signals.set(area, signal);
3921
+ }
3922
+ signal.active += 1;
3923
+ if (attempt > 1 || status === "failed") {
3924
+ signal.retrying += 1;
3925
+ }
3926
+ if (latencySampleMs > 0) {
3927
+ signal.mergeLatencyMsTotal += latencySampleMs;
3928
+ signal.mergeLatencySamples += 1;
3929
+ signal.maxMergeLatencyMs = Math.max(
3930
+ signal.maxMergeLatencyMs,
3931
+ latencySampleMs,
3932
+ );
3933
+ }
3934
+ }
3935
+ }
3936
+ return signals;
3937
+ }
3938
+
3939
+ _computeRepoAreaEffectiveLimit(area, activeSignals = null) {
3940
+ const configuredLimit = Number(this.repoAreaParallelLimit || 0);
3941
+ if (!Number.isFinite(configuredLimit) || configuredLimit <= 0) {
3942
+ return 0;
3943
+ }
3944
+ if (configuredLimit <= 1) return 1;
3945
+
3946
+ const signal = activeSignals?.get(area) || {
3947
+ active: 0,
3948
+ retrying: 0,
3949
+ mergeLatencyMsTotal: 0,
3950
+ mergeLatencySamples: 0,
3951
+ };
3952
+ const failureRate =
3953
+ signal.active > 0 ? signal.retrying / signal.active : 0;
3954
+ const averageMergeLatencyMs =
3955
+ signal.mergeLatencySamples > 0
3956
+ ? signal.mergeLatencyMsTotal / signal.mergeLatencySamples
3957
+ : 0;
3958
+
3959
+ let penalty = 0;
3960
+ if (signal.active > 0 && failureRate >= 0.5) {
3961
+ penalty += 1;
3962
+ }
3963
+ if (
3964
+ signal.active > 0 &&
3965
+ averageMergeLatencyMs >= REPO_AREA_SLOW_MERGE_LATENCY_MS
3966
+ ) {
3967
+ penalty += 1;
3968
+ }
3969
+ if (
3970
+ signal.active > 1 &&
3971
+ failureRate >= 0.75 &&
3972
+ averageMergeLatencyMs >= REPO_AREA_VERY_SLOW_MERGE_LATENCY_MS
3973
+ ) {
3974
+ penalty += 1;
3975
+ }
3976
+
3977
+ return Math.max(
3978
+ 1,
3979
+ configuredLimit - Math.min(configuredLimit - 1, penalty),
3980
+ );
3981
+ }
3982
+
3983
+ _buildRepoAreaLockStatus(now = Date.now()) {
3984
+ const activeCounts = this._buildActiveRepoAreaCounts();
3985
+ const activeSignals = this._buildActiveRepoAreaSignals(now);
3986
+ const waitingCounts = new Map();
3987
+ for (const pending of this._repoAreaPendingWaits.values()) {
3988
+ waitingCounts.set(
3989
+ pending.area,
3990
+ (waitingCounts.get(pending.area) || 0) + 1,
3991
+ );
3992
+ }
3993
+
3994
+ const areas = new Set([
3995
+ ...activeCounts.keys(),
3996
+ ...activeSignals.keys(),
3997
+ ...waitingCounts.keys(),
3998
+ ...this._repoAreaLockMetrics.keys(),
3999
+ ]);
4000
+
4001
+ const items = Array.from(areas)
4002
+ .sort()
4003
+ .map((area) => {
4004
+ const metric = this._getRepoAreaLockMetric(area) || {
4005
+ conflicts: 0,
4006
+ blockedDispatches: 0,
4007
+ selectedDispatches: 0,
4008
+ waitMsTotal: 0,
4009
+ waitSamples: 0,
4010
+ maxWaitMs: 0,
4011
+ lastConflictAt: null,
4012
+ lastSelectedAt: null,
4013
+ };
4014
+ const signal = activeSignals.get(area) || {
4015
+ active: 0,
4016
+ retrying: 0,
4017
+ mergeLatencyMsTotal: 0,
4018
+ mergeLatencySamples: 0,
4019
+ maxMergeLatencyMs: 0,
4020
+ };
4021
+ const active = activeCounts.get(area) || 0;
4022
+ const waitingTasks = waitingCounts.get(area) || 0;
4023
+ const activeFailureRate =
4024
+ signal.active > 0 ? signal.retrying / signal.active : 0;
4025
+ const averageMergeLatencyMs =
4026
+ signal.mergeLatencySamples > 0
4027
+ ? signal.mergeLatencyMsTotal / signal.mergeLatencySamples
4028
+ : 0;
4029
+
4030
+ return {
4031
+ area,
4032
+ configuredLimit: Number(this.repoAreaParallelLimit || 0),
4033
+ effectiveLimit: this._computeRepoAreaEffectiveLimit(area, activeSignals),
4034
+ activeSlots: active,
4035
+ waitingTasks,
4036
+ activeFailureRate,
4037
+ averageMergeLatencyMs,
4038
+ maxMergeLatencyMs: signal.maxMergeLatencyMs || 0,
4039
+ conflicts: metric.conflicts,
4040
+ blockedDispatches: metric.blockedDispatches,
4041
+ selectedDispatches: metric.selectedDispatches,
4042
+ averageWaitMs:
4043
+ metric.waitSamples > 0 ? metric.waitMsTotal / metric.waitSamples : 0,
4044
+ maxWaitMs: metric.maxWaitMs,
4045
+ waitSamples: metric.waitSamples,
4046
+ lastConflictAt: metric.lastConflictAt,
4047
+ lastSelectedAt: metric.lastSelectedAt,
4048
+ };
4049
+ });
4050
+
4051
+ return {
4052
+ enabled: Number(this.repoAreaParallelLimit || 0) > 0,
4053
+ configuredLimit: Number(this.repoAreaParallelLimit || 0),
4054
+ areas: items,
4055
+ totals: {
4056
+ conflicts: items.reduce((sum, item) => sum + item.conflicts, 0),
4057
+ blockedDispatches: items.reduce(
4058
+ (sum, item) => sum + item.blockedDispatches,
4059
+ 0,
4060
+ ),
4061
+ waitingTasks: items.reduce((sum, item) => sum + item.waitingTasks, 0),
4062
+ },
4063
+ lastDispatch: {
4064
+ ...this._repoAreaDispatchCycle,
4065
+ blockedByArea: { ...this._repoAreaDispatchCycle.blockedByArea },
4066
+ saturatedAreas: [...this._repoAreaDispatchCycle.saturatedAreas],
4067
+ },
4068
+ };
4069
+ }
3431
4070
  _selectTasksForBaseBranchLimit(candidates, remaining) {
3432
4071
  if (!Array.isArray(candidates) || candidates.length === 0) return [];
3433
4072
  if (!Number.isFinite(remaining) || remaining <= 0) return [];
@@ -3440,12 +4079,29 @@ class TaskExecutor {
3440
4079
  return candidates.slice(0, remaining);
3441
4080
  }
3442
4081
 
4082
+ const now = Date.now();
4083
+ this._pruneRepoAreaPendingWaits(candidates, now);
4084
+
3443
4085
  const baseBranchCounts = enforceBaseBranch
3444
4086
  ? this._buildActiveBaseBranchCounts()
3445
4087
  : new Map();
3446
4088
  const repoAreaCounts = enforceRepoArea
3447
4089
  ? this._buildActiveRepoAreaCounts()
3448
4090
  : new Map();
4091
+ const activeRepoAreaSignals = enforceRepoArea
4092
+ ? this._buildActiveRepoAreaSignals(now)
4093
+ : new Map();
4094
+ const effectiveRepoAreaLimits = new Map();
4095
+ this._repoAreaDispatchCycle = {
4096
+ cycle: Number(this._repoAreaDispatchCycle?.cycle || 0) + 1,
4097
+ at: new Date(now).toISOString(),
4098
+ candidateCount: candidates.length,
4099
+ remaining,
4100
+ selectedCount: 0,
4101
+ blockedTasks: 0,
4102
+ blockedByArea: {},
4103
+ saturatedAreas: [],
4104
+ };
3449
4105
 
3450
4106
  const selected = [];
3451
4107
  for (const task of candidates) {
@@ -3462,10 +4118,17 @@ class TaskExecutor {
3462
4118
 
3463
4119
  if (enforceRepoArea) {
3464
4120
  const areas = this._extractTaskRepoAreas(task);
3465
- if (
3466
- areas.length > 0 &&
3467
- areas.some((area) => (repoAreaCounts.get(area) || 0) >= repoAreaLimit)
3468
- ) {
4121
+ const blockedAreas = areas.filter((area) => {
4122
+ if (!effectiveRepoAreaLimits.has(area)) {
4123
+ effectiveRepoAreaLimits.set(
4124
+ area,
4125
+ this._computeRepoAreaEffectiveLimit(area, activeRepoAreaSignals),
4126
+ );
4127
+ }
4128
+ const effectiveLimit = effectiveRepoAreaLimits.get(area) || repoAreaLimit;
4129
+ return (repoAreaCounts.get(area) || 0) >= effectiveLimit;
4130
+ });
4131
+ if (blockedAreas.length > 0) {
3469
4132
  if (enforceBaseBranch) {
3470
4133
  const key = this._resolveTaskBaseBranchKey(task);
3471
4134
  if (key) {
@@ -3473,16 +4136,39 @@ class TaskExecutor {
3473
4136
  baseBranchCounts.set(key, Math.max(0, current - 1));
3474
4137
  }
3475
4138
  }
4139
+ this._repoAreaDispatchCycle.blockedTasks += 1;
4140
+ for (const area of blockedAreas) {
4141
+ const metric = this._getRepoAreaLockMetric(area);
4142
+ if (metric) {
4143
+ metric.conflicts += 1;
4144
+ metric.blockedDispatches += 1;
4145
+ metric.lastConflictAt = this._repoAreaDispatchCycle.at;
4146
+ }
4147
+ this._beginRepoAreaWait(task?.id || task?.task_id, area, now);
4148
+ this._repoAreaDispatchCycle.blockedByArea[area] =
4149
+ (this._repoAreaDispatchCycle.blockedByArea[area] || 0) + 1;
4150
+ }
3476
4151
  continue;
3477
4152
  }
4153
+ this._clearRepoAreaWaitsForTask(task, now);
3478
4154
  for (const area of areas) {
3479
4155
  repoAreaCounts.set(area, (repoAreaCounts.get(area) || 0) + 1);
4156
+ const metric = this._getRepoAreaLockMetric(area);
4157
+ if (metric) {
4158
+ metric.selectedDispatches += 1;
4159
+ metric.lastSelectedAt = this._repoAreaDispatchCycle.at;
4160
+ }
3480
4161
  }
3481
4162
  }
3482
4163
 
3483
4164
  selected.push(task);
3484
4165
  }
3485
4166
 
4167
+ this._repoAreaDispatchCycle.selectedCount = selected.length;
4168
+ this._repoAreaDispatchCycle.saturatedAreas = Object.keys(
4169
+ this._repoAreaDispatchCycle.blockedByArea,
4170
+ ).sort();
4171
+
3486
4172
  return selected;
3487
4173
  }
3488
4174
  _isBaseBranchLimitReached(task) {
@@ -4118,5 +4804,3 @@ export function isExecutorDisabled() {
4118
4804
 
4119
4805
  export { TaskExecutor };
4120
4806
  export default TaskExecutor;
4121
-
4122
-