@zq-silk/yui 0.6.4 → 0.6.6

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.
@@ -2,7 +2,7 @@ import { reconciliationIntervalMilliseconds } from "../config/yuiConfig.js";
2
2
  import { processLeaderWakeups } from "../scheduler/leaderWakeupProcessor.js";
3
3
  import { pendingWakeupsMatch } from "../scheduler/pendingWakeup.js";
4
4
  import { processActiveRoleRunDeliveries } from "../scheduler/activeRoleRunDelivery.js";
5
- import { selectedSchedulerRoles, selectedSchedulerTasks } from "../scheduler/ports.js";
5
+ import { selectedActiveSchedulerTasks, selectedSchedulerRoles, selectedSchedulerTasks } from "../scheduler/ports.js";
6
6
  import { reconcileExitedRoleRuns } from "../scheduler/roleRunLiveness.js";
7
7
  import { DEFAULT_STALL_WINDOW_MS, reconcileStalledRoleRuns } from "../scheduler/roleRunStall.js";
8
8
  import { repairOrphanedActiveTasks } from "../scheduler/activeTaskProgress.js";
@@ -10,6 +10,7 @@ import { processOperatorInputNotifications } from "../scheduler/operatorInputNot
10
10
  import { startControllerServer } from "../core/controllerServer.js";
11
11
  import { monotonicMilliseconds } from "../core/controllerTelemetry.js";
12
12
  import { isProjectMaintenanceFenced } from "../repository/projectMaintenanceLock.js";
13
+ import { KeyedWorkQueue } from "../coordination/keyedWorkQueue.js";
13
14
  import { MailboxScheduler } from "../coordination/mailboxScheduler.js";
14
15
  import { nearestDeadlineBatch } from "../coordination/deadlineScheduler.js";
15
16
  import { hasRuntimeCleanupObligation, isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
@@ -21,6 +22,8 @@ const DEFAULT_RUNTIME_OBSERVER_INTERVAL_MS = 1_000;
21
22
  const DEFAULT_DELIVERY_RETRY_MS = 250;
22
23
  const DEFAULT_DELIVERY_RETRY_LIMIT = 60;
23
24
  const DEFAULT_TASK_ORCHESTRATION_RETRY_LIMIT = 2;
25
+ const DEFAULT_TASK_CONCURRENCY = 4;
26
+ const MAX_TASK_CONCURRENCY = 32;
24
27
  const RUNTIME_RESERVATION_RECOVERY_AGE_MS = 120_000;
25
28
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
26
29
  const CONTROLLER_LATENCY_BUCKETS_MS = [10, 50, 100, 250, 500, 1_000, 3_000];
@@ -40,18 +43,18 @@ const ZERO_DRAIN_METRICS = Object.freeze({
40
43
  * Runs one lean scheduler pass. Due native Turn completions are folded before
41
44
  * liveness, so a valid Hook boundary fences destructive process reconciliation.
42
45
  */
43
- export async function runControllerSchedulerPass(store, delivery, now, workspacePreparer, scope = { kind: "full" }, includeOperator = true, runtimeCleanupOutcomes = [], lifecycleHost, stallWindowMs = DEFAULT_STALL_WINDOW_MS, maintenanceFence, onMaintenanceFenceDefer) {
46
+ export async function runControllerSchedulerPass(store, delivery, now, workspacePreparer, scope = { kind: "full" }, includeOperator = true, runtimeCleanupOutcomes = [], lifecycleHost, stallWindowMs = DEFAULT_STALL_WINDOW_MS, maintenanceFence, onMaintenanceFenceDefer, blockedTaskIds = new Set()) {
44
47
  const compiledSelection = compileReconcileSelection(scope);
45
48
  const selection = includeOperator
46
- ? compiledSelection
47
- : { ...compiledSelection, operator: false };
49
+ ? { ...compiledSelection, blockedTaskIds }
50
+ : { ...compiledSelection, operator: false, blockedTaskIds };
48
51
  queueSelectedCompletedTaskRuntimeCleanups(store, selection, now);
49
52
  // A full-state Task projection can be individually bounded yet still starve
50
53
  // control sockets when several scheduler phases repeat it in one native
51
54
  // event-loop turn. Give already-written requests a poll boundary before the
52
55
  // next durable phase; later phases retain their existing CAS fences.
53
56
  await controlEventLoopTurn();
54
- const failedCleanupRoles = await processSelectedRoleRuntimeCleanups(store, delivery, lifecycleHost, scope, now, runtimeCleanupOutcomes);
57
+ const failedCleanupRoles = await processSelectedRoleRuntimeCleanups(store, delivery, lifecycleHost, scope, now, runtimeCleanupOutcomes, blockedTaskIds);
55
58
  const roleSelection = selectionWithoutFailedCleanupRoles(store, selection, failedCleanupRoles);
56
59
  const wakeupSelection = selectionWithoutFailedLeaderCleanupTasks(store, selection, failedCleanupRoles);
57
60
  if (selection.full)
@@ -107,16 +110,17 @@ export async function runControllerSchedulerPass(store, delivery, now, workspace
107
110
  await controlEventLoopTurn();
108
111
  const liveStatuses = new Map();
109
112
  const resourceEvidence = new Map();
110
- const failedRunRefs = await reconcileExitedRoleRuns(store, delivery, now, roleSelection, unsettledRunRefs, liveStatuses, resourceEvidence);
113
+ const failedRunRefs = await reconcileExitedRoleRuns(store, delivery, now, roleSelection, unsettledRunRefs, liveStatuses, resourceEvidence, scope.kind === "dirty");
111
114
  await controlEventLoopTurn();
112
115
  await reconcileStalledRoleRuns(store, delivery, now, roleSelection, stallWindowMs, liveStatuses, resourceEvidence);
113
116
  await controlEventLoopTurn();
114
- await reconcileDormantRuntimeOwners(store, delivery, lifecycleHost, scope, now);
115
- const autoResolvedInputs = selection.full
117
+ await reconcileDormantRuntimeOwners(store, delivery, lifecycleHost, scope, now, selection.blockedTaskIds);
118
+ const selectedInputTaskIds = selectedTaskIdsForBoundedPass(store, selection);
119
+ const autoResolvedInputs = selectedInputTaskIds === undefined
116
120
  ? store.resolveExpiredInputRecommendations(now)
117
- : selection.taskIds.size === 0
121
+ : selectedInputTaskIds.size === 0
118
122
  ? []
119
- : store.resolveExpiredInputRecommendations(now, selection.taskIds);
123
+ : store.resolveExpiredInputRecommendations(now, selectedInputTaskIds);
120
124
  // Liveness and auto-resolution can durably queue new Leader work. Process
121
125
  // only Tasks that were not part of phase one, except when a same-pass due
122
126
  // completion made an initially busy Leader idle. Result order remains
@@ -160,8 +164,10 @@ export async function runControllerSchedulerPass(store, delivery, now, workspace
160
164
  }
161
165
  function selectedPendingWakeups(store, selection) {
162
166
  const wakeups = selection.full
163
- ? store.listPendingWakeups()
167
+ ? store.listPendingWakeups().filter((wakeup) => (!selection.blockedTaskIds?.has(wakeup.taskId)))
164
168
  : [...selection.taskIds].flatMap((taskId) => {
169
+ if (selection.blockedTaskIds?.has(taskId))
170
+ return [];
165
171
  const wakeup = store.getPendingWakeup(taskId);
166
172
  return wakeup === null ? [] : [wakeup];
167
173
  });
@@ -176,7 +182,8 @@ function exactTaskSelection(taskIds) {
176
182
  taskIds,
177
183
  allRoleTaskIds: new Set(),
178
184
  rolesByTask: new Map(),
179
- operator: false
185
+ operator: false,
186
+ blockedTaskIds: new Set()
180
187
  };
181
188
  }
182
189
  function mergeWakeupPhaseResults(initial, later) {
@@ -197,6 +204,27 @@ function mergeWakeupPhaseResults(initial, later) {
197
204
  function queueSelectedCompletedTaskRuntimeCleanups(store, selection, now) {
198
205
  if (store.enqueueRuntimeCleanup === undefined)
199
206
  return;
207
+ if (selection.full && store.listRuntimeSessionCandidates !== undefined) {
208
+ for (const candidate of store.listRuntimeSessionCandidates({
209
+ cleanupRequiredOnly: true
210
+ })) {
211
+ if (candidate.owner.scope !== "task"
212
+ || !candidate.cleanupRequired
213
+ || selection.blockedTaskIds?.has(candidate.owner.taskId)) {
214
+ continue;
215
+ }
216
+ const { taskId, roleName } = candidate.owner;
217
+ if (store.getTask(taskId)?.status !== "completed")
218
+ continue;
219
+ if (store.getActiveAgentRun(taskId, roleName) !== null)
220
+ continue;
221
+ const target = runtimeLifecycleTarget(candidate.owner);
222
+ if (hasRuntimeCleanupObligation(store.getWorkMailbox(target)))
223
+ continue;
224
+ store.enqueueRuntimeCleanup(candidate.owner, now);
225
+ }
226
+ return;
227
+ }
200
228
  for (const task of selectedSchedulerTasks(store, selection)) {
201
229
  if (task.status !== "completed")
202
230
  continue;
@@ -227,8 +255,8 @@ function schedulerSessionRequiresRuntimeCleanup(session) {
227
255
  }
228
256
  return session.status === "running" || session.launchId !== undefined;
229
257
  }
230
- async function processSelectedRoleRuntimeCleanups(store, delivery, lifecycleHost, scope, now, outcomes) {
231
- const targets = selectedRuntimeLifecycleTargets(store, scope);
258
+ async function processSelectedRoleRuntimeCleanups(store, delivery, lifecycleHost, scope, now, outcomes, blockedTaskIds = new Set()) {
259
+ const targets = selectedRuntimeLifecycleTargets(store, scope, blockedTaskIds);
232
260
  const failedRoles = new Set();
233
261
  for (const target of targets) {
234
262
  const mailbox = store.getWorkMailbox(target);
@@ -315,14 +343,15 @@ async function processSelectedRoleRuntimeCleanups(store, delivery, lifecycleHost
315
343
  }
316
344
  return failedRoles;
317
345
  }
318
- async function reconcileDormantRuntimeOwners(store, delivery, lifecycleHost, scope, now) {
346
+ async function reconcileDormantRuntimeOwners(store, delivery, lifecycleHost, scope, now, blockedTaskIds = new Set()) {
319
347
  if (scope.kind !== "full"
320
348
  || lifecycleHost === undefined
321
349
  || store.listDormantRuntimeOwners === undefined
322
350
  || store.markRuntimeOwnerStopped === undefined) {
323
351
  return;
324
352
  }
325
- const candidates = store.listDormantRuntimeOwners();
353
+ const candidates = store.listDormantRuntimeOwners().filter((candidate) => (candidate.owner.scope !== "task"
354
+ || !blockedTaskIds.has(candidate.owner.taskId)));
326
355
  if (candidates.length === 0)
327
356
  return;
328
357
  const owners = candidates.map((candidate) => candidate.owner);
@@ -386,11 +415,13 @@ function runtimeOwnerIdentity(owner) {
386
415
  function resolveDueRuntimeTurnCompletions(store, delivery, selection, now) {
387
416
  if (typeof store.resolveDueRuntimeTurnCompletions !== "function")
388
417
  return;
389
- const selectedTaskIds = selection.full ? undefined : selection.taskIds;
418
+ const selectedTaskIds = selectedTaskIdsForBoundedPass(store, selection);
390
419
  if (selectedTaskIds?.size === 0)
391
420
  return;
392
- const candidates = store.listPendingRuntimeTurnCompletions().filter((completion) => (selectedTaskIds === undefined
393
- || selectedTaskIds.has(completion.taskId)));
421
+ const candidateTaskIds = selectedTaskIds === undefined
422
+ ? undefined
423
+ : [...selectedTaskIds].sort((left, right) => (left.localeCompare(right, undefined, { numeric: true })));
424
+ const candidates = store.listPendingRuntimeTurnCompletions(candidateTaskIds);
394
425
  const finalized = new Set(store.resolveDueRuntimeTurnCompletions(now, selectedTaskIds));
395
426
  if (finalized.size === 0)
396
427
  return;
@@ -413,22 +444,46 @@ function resolveDueRuntimeTurnCompletions(store, delivery, selection, now) {
413
444
  function resolveDueProviderRetries(store, selection, now) {
414
445
  if (typeof store.resolveDueProviderRetries !== "function")
415
446
  return;
416
- const selectedTaskIds = selection.full ? undefined : selection.taskIds;
447
+ const selectedTaskIds = selectedTaskIdsForBoundedPass(store, selection);
417
448
  if (selectedTaskIds?.size === 0)
418
449
  return;
419
450
  store.resolveDueProviderRetries(now, selectedTaskIds);
420
451
  }
421
- function selectedRuntimeLifecycleTargets(store, scope) {
452
+ function selectedTaskIdsForBoundedPass(store, selection) {
453
+ if (!selection.full) {
454
+ if ((selection.blockedTaskIds?.size ?? 0) === 0)
455
+ return selection.taskIds;
456
+ return new Set([...selection.taskIds].filter((taskId) => (!selection.blockedTaskIds.has(taskId))));
457
+ }
458
+ if ((selection.blockedTaskIds?.size ?? 0) === 0)
459
+ return undefined;
460
+ return new Set(selectedActiveSchedulerTasks(store, selection).map((task) => task.id));
461
+ }
462
+ function selectedReadyWorkMailboxes(store) {
463
+ return store.listReadyWorkMailboxes?.() ?? store.listWorkMailboxes();
464
+ }
465
+ function selectedRuntimeLifecycleTargets(store, scope, blockedTaskIds = new Set()) {
422
466
  if (scope.kind === "full") {
423
- return store.listWorkMailboxes().flatMap((mailbox) => (mailbox.target.kind === "role-runtime"
424
- || mailbox.target.kind === "global-role-runtime"
425
- ? [mailbox.target]
426
- : []));
467
+ return selectedReadyWorkMailboxes(store).flatMap((mailbox) => {
468
+ if (mailbox.target.kind === "role-runtime") {
469
+ return blockedTaskIds.has(mailbox.target.taskId)
470
+ ? []
471
+ : [mailbox.target];
472
+ }
473
+ return mailbox.target.kind === "global-role-runtime"
474
+ ? [mailbox.target]
475
+ : [];
476
+ });
427
477
  }
428
478
  const targets = new Map();
429
479
  for (const key of scope.keys) {
430
480
  const parsed = parseMailboxKey(key);
431
481
  if (parsed.kind === "task") {
482
+ if (blockedTaskIds.has(parsed.taskId))
483
+ continue;
484
+ const task = store.getTask(parsed.taskId);
485
+ if (task?.status !== "active" && task?.status !== "completed")
486
+ continue;
432
487
  for (const role of store.listRoles(parsed.taskId)) {
433
488
  const target = {
434
489
  kind: "role-runtime",
@@ -439,6 +494,8 @@ function selectedRuntimeLifecycleTargets(store, scope) {
439
494
  }
440
495
  }
441
496
  else if (parsed.kind === "role") {
497
+ if (blockedTaskIds.has(parsed.taskId))
498
+ continue;
442
499
  const target = {
443
500
  kind: "role-runtime",
444
501
  taskId: parsed.taskId,
@@ -497,7 +554,7 @@ function selectionWithoutFailedCleanupRoles(store, selection, failedRoles) {
497
554
  if (failedRoles.size === 0)
498
555
  return selection;
499
556
  const taskIds = selection.full
500
- ? new Set(store.listTasks().map((task) => task.id))
557
+ ? new Set(selectedActiveSchedulerTasks(store, selection).map((task) => task.id))
501
558
  : new Set(selection.taskIds);
502
559
  const rolesByTask = new Map();
503
560
  for (const taskId of taskIds) {
@@ -511,7 +568,8 @@ function selectionWithoutFailedCleanupRoles(store, selection, failedRoles) {
511
568
  taskIds,
512
569
  allRoleTaskIds: new Set(),
513
570
  rolesByTask,
514
- operator: selection.operator
571
+ operator: selection.operator,
572
+ blockedTaskIds: selection.blockedTaskIds
515
573
  };
516
574
  }
517
575
  function selectionWithoutFailedLeaderCleanupTasks(store, selection, failedRoles) {
@@ -519,7 +577,7 @@ function selectionWithoutFailedLeaderCleanupTasks(store, selection, failedRoles)
519
577
  return selection;
520
578
  }
521
579
  const taskIds = selection.full
522
- ? new Set(store.listTasks().map((task) => task.id))
580
+ ? new Set(selectedActiveSchedulerTasks(store, selection).map((task) => task.id))
523
581
  : new Set(selection.taskIds);
524
582
  for (const taskId of taskIds) {
525
583
  if (failedRoles.has(roleIdentity(taskId, "leader")))
@@ -530,7 +588,8 @@ function selectionWithoutFailedLeaderCleanupTasks(store, selection, failedRoles)
530
588
  taskIds,
531
589
  allRoleTaskIds: new Set(),
532
590
  rolesByTask: new Map(),
533
- operator: selection.operator
591
+ operator: selection.operator,
592
+ blockedTaskIds: selection.blockedTaskIds
534
593
  };
535
594
  }
536
595
  function roleIdentity(taskId, roleName) {
@@ -538,8 +597,13 @@ function roleIdentity(taskId, roleName) {
538
597
  }
539
598
  function claimSelectedTaskMailboxes(store, selection, now) {
540
599
  const targets = selection.full
541
- ? store.listWorkMailboxes().flatMap((mailbox) => (mailbox.target.kind === "task" ? [mailbox.target] : []))
542
- : [...selection.allRoleTaskIds].map((taskId) => ({ kind: "task", taskId }));
600
+ ? selectedReadyWorkMailboxes(store).flatMap((mailbox) => (mailbox.target.kind === "task"
601
+ && !selection.blockedTaskIds?.has(mailbox.target.taskId)
602
+ ? [mailbox.target]
603
+ : []))
604
+ : [...selection.allRoleTaskIds]
605
+ .filter((taskId) => !selection.blockedTaskIds?.has(taskId))
606
+ .map((taskId) => ({ kind: "task", taskId }));
543
607
  const claims = [];
544
608
  for (const target of targets) {
545
609
  const mailbox = store.getWorkMailbox(target);
@@ -565,7 +629,8 @@ export function compileReconcileSelection(scope) {
565
629
  taskIds: new Set(),
566
630
  allRoleTaskIds: new Set(),
567
631
  rolesByTask: new Map(),
568
- operator: true
632
+ operator: true,
633
+ blockedTaskIds: new Set()
569
634
  };
570
635
  }
571
636
  const taskIds = new Set();
@@ -593,44 +658,46 @@ export function compileReconcileSelection(scope) {
593
658
  taskIds,
594
659
  allRoleTaskIds,
595
660
  rolesByTask: mutableRoles,
596
- operator
661
+ operator,
662
+ blockedTaskIds: new Set()
597
663
  };
598
664
  }
599
665
  async function prepareActiveWorkspaces(store, workspace, selection, maintenanceFence, onMaintenanceFenceDefer) {
600
666
  if (workspace === undefined)
601
667
  return { failed: new Set(), ready: new Set() };
602
- const taskIds = selection.full
603
- ? new Set(store.listTasks()
604
- .filter((task) => task.status === "active")
605
- .map((task) => task.id))
606
- : new Set(selection.allRoleTaskIds);
668
+ const tasks = selection.full
669
+ ? selectedActiveSchedulerTasks(store, selection)
670
+ : [...selection.allRoleTaskIds].flatMap((taskId) => {
671
+ if (selection.blockedTaskIds?.has(taskId))
672
+ return [];
673
+ const task = store.getTask(taskId);
674
+ return task?.status === "active" ? [task] : [];
675
+ });
607
676
  const failed = new Set();
608
677
  const ready = new Set();
609
- for (const taskId of taskIds) {
610
- const task = store.getTask(taskId);
611
- if (task?.status === "active") {
612
- // A Project under maintenance is fenced: defer this Task's preparation
613
- // for the pass. The deferral is per-Project, never a Controller stop,
614
- // and a deferred Task is not marked failed.
615
- if (maintenanceFence !== undefined) {
616
- const fencedProjects = task.projectBindings
617
- .map(({ projectId }) => projectId)
618
- .filter((projectId) => maintenanceFence(projectId));
619
- if (fencedProjects.length > 0) {
620
- onMaintenanceFenceDefer?.({ taskId, projectIds: fencedProjects });
621
- continue;
622
- }
623
- }
624
- try {
625
- const result = await workspace.prepareTaskWorkspace(taskId);
626
- if (result.status === "failed")
627
- failed.add(taskId);
628
- else
629
- ready.add(taskId);
678
+ for (const task of tasks) {
679
+ const taskId = task.id;
680
+ // A Project under maintenance is fenced: defer this Task's preparation
681
+ // for the pass. The deferral is per-Project, never a Controller stop,
682
+ // and a deferred Task is not marked failed.
683
+ if (maintenanceFence !== undefined) {
684
+ const fencedProjects = task.projectBindings
685
+ .map(({ projectId }) => projectId)
686
+ .filter((projectId) => maintenanceFence(projectId));
687
+ if (fencedProjects.length > 0) {
688
+ onMaintenanceFenceDefer?.({ taskId, projectIds: fencedProjects });
689
+ continue;
630
690
  }
631
- catch {
691
+ }
692
+ try {
693
+ const result = await workspace.prepareTaskWorkspace(taskId);
694
+ if (result.status === "failed")
632
695
  failed.add(taskId);
633
- }
696
+ else
697
+ ready.add(taskId);
698
+ }
699
+ catch {
700
+ failed.add(taskId);
634
701
  }
635
702
  }
636
703
  return { failed, ready };
@@ -675,10 +742,69 @@ function mailboxPart(value, key) {
675
742
  throw new TypeError(`Controller mailbox key is invalid: ${key}.`);
676
743
  }
677
744
  }
745
+ function partitionDirtyScope(keys) {
746
+ const taskScopes = new Map();
747
+ const globalKeys = new Set();
748
+ for (const key of keys) {
749
+ const parsed = parseMailboxKey(key);
750
+ if (parsed.kind === "operator" || parsed.kind === "global-role") {
751
+ globalKeys.add(key);
752
+ continue;
753
+ }
754
+ const current = taskScopes.get(parsed.taskId) ?? {
755
+ taskKey: undefined,
756
+ roleKeys: new Map()
757
+ };
758
+ if (parsed.kind === "task") {
759
+ current.taskKey = key;
760
+ current.roleKeys.clear();
761
+ }
762
+ else if (current.taskKey === undefined) {
763
+ current.roleKeys.set(parsed.roleName, key);
764
+ }
765
+ taskScopes.set(parsed.taskId, current);
766
+ }
767
+ return {
768
+ taskScopes: [...taskScopes].map(([taskId, scope]) => ({
769
+ taskId,
770
+ keys: scope.taskKey === undefined
771
+ ? [...scope.roleKeys.values()]
772
+ : [scope.taskKey]
773
+ })),
774
+ globalKeys: [...globalKeys]
775
+ };
776
+ }
777
+ function emptyControllerSchedulerResult() {
778
+ return {
779
+ activeRunDeliveries: [],
780
+ failedRunRefs: [],
781
+ wakeups: [],
782
+ inputNotifications: [],
783
+ autoResolvedInputs: []
784
+ };
785
+ }
786
+ function runtimeTaskFailureIds(result) {
787
+ if (result === undefined)
788
+ return new Set();
789
+ return new Set(result.failed.flatMap((failure) => (failure.scope === "task"
790
+ && typeof failure.taskId === "string"
791
+ && failure.taskId.length > 0
792
+ ? [failure.taskId]
793
+ : [])));
794
+ }
795
+ function mergeControllerSchedulerResults(results) {
796
+ return {
797
+ activeRunDeliveries: results.flatMap((result) => result.activeRunDeliveries),
798
+ failedRunRefs: results.flatMap((result) => result.failedRunRefs),
799
+ wakeups: results.flatMap((result) => result.wakeups),
800
+ inputNotifications: results.flatMap((result) => result.inputNotifications),
801
+ autoResolvedInputs: results.flatMap((result) => result.autoResolvedInputs)
802
+ };
803
+ }
678
804
  /**
679
- * Single-owner periodic runtime for FileTaskStore-backed scheduling. Concurrent
680
- * pump requests coalesce into one follow-up pass; scheduler effects never
681
- * overlap. There are deliberately no filesystem watchers or derived indexes.
805
+ * Single-owner periodic runtime for FileTaskStore-backed scheduling. Full pump
806
+ * requests remain exclusive, while dirty work is serialized per Task and may
807
+ * progress concurrently across different Tasks up to the configured bound.
682
808
  */
683
809
  export class FileTaskController {
684
810
  store;
@@ -690,6 +816,7 @@ export class FileTaskController {
690
816
  #deliveryRetryMs;
691
817
  #deliveryRetryLimit;
692
818
  #taskOrchestrationRetryLimit;
819
+ #taskConcurrency;
693
820
  #stallWindowMs;
694
821
  #runtimeEventProcessor;
695
822
  #runtimeObserver;
@@ -697,6 +824,9 @@ export class FileTaskController {
697
824
  #lifecycleHost;
698
825
  #deliveryRetryAttempts = new Map();
699
826
  #deliveryRetryTimers = new Map();
827
+ #taskPassRetryAttempts = new Map();
828
+ #taskPassRetryTimers = new Map();
829
+ #dirtyTaskQueue;
700
830
  #passRetryTimer;
701
831
  #passRetryAttempt = 0;
702
832
  #timer;
@@ -735,6 +865,7 @@ export class FileTaskController {
735
865
  this.#deliveryRetryMs = positiveInteger(options.deliveryRetryMs, DEFAULT_DELIVERY_RETRY_MS, "Controller delivery retry delay");
736
866
  this.#deliveryRetryLimit = positiveInteger(options.deliveryRetryLimit, DEFAULT_DELIVERY_RETRY_LIMIT, "Controller delivery retry limit");
737
867
  this.#taskOrchestrationRetryLimit = positiveInteger(options.taskOrchestrationRetryLimit, DEFAULT_TASK_ORCHESTRATION_RETRY_LIMIT, "Controller Task orchestration retry limit");
868
+ this.#taskConcurrency = boundedPositiveInteger(options.taskConcurrency, DEFAULT_TASK_CONCURRENCY, MAX_TASK_CONCURRENCY, "Controller Task concurrency");
738
869
  this.#stallWindowMs = positiveInteger(options.stallWindowMs, DEFAULT_STALL_WINDOW_MS, "Controller Run stall window");
739
870
  this.#runtimeEventProcessor = options.runtimeEventProcessor;
740
871
  this.#runtimeObserver = options.runtimeObserver;
@@ -862,6 +993,8 @@ export class FileTaskController {
862
993
  clearTimeout(timer);
863
994
  this.#deliveryRetryTimers.clear();
864
995
  this.#deliveryRetryAttempts.clear();
996
+ this.#clearAllTaskPassRetries();
997
+ void this.#dirtyTaskQueue?.abortPending();
865
998
  if (this.#passRetryTimer !== undefined) {
866
999
  clearTimeout(this.#passRetryTimer);
867
1000
  this.#passRetryTimer = undefined;
@@ -924,6 +1057,8 @@ export class FileTaskController {
924
1057
  if (scope.kind === "full") {
925
1058
  this.#pendingFull = true;
926
1059
  this.#pendingKeys.clear();
1060
+ this.#clearAllTaskPassRetries();
1061
+ void this.#dirtyTaskQueue?.abortPending();
927
1062
  }
928
1063
  else if (!this.#pendingFull) {
929
1064
  for (const key of scope.keys)
@@ -944,13 +1079,7 @@ export class FileTaskController {
944
1079
  void this.pump().catch(this.#onError);
945
1080
  }
946
1081
  async #runCoalesced() {
947
- let result = {
948
- activeRunDeliveries: [],
949
- failedRunRefs: [],
950
- wakeups: [],
951
- inputNotifications: [],
952
- autoResolvedInputs: []
953
- };
1082
+ let result = emptyControllerSchedulerResult();
954
1083
  let pendingRuntimeDrain = false;
955
1084
  try {
956
1085
  while (this.#pendingFull || this.#pendingKeys.size > 0 || pendingRuntimeDrain) {
@@ -958,6 +1087,8 @@ export class FileTaskController {
958
1087
  ? { kind: "full" }
959
1088
  : { kind: "dirty", keys: [...this.#pendingKeys] };
960
1089
  const runtimeCleanupOutcomes = [];
1090
+ const runtimeFailedTaskIds = new Set();
1091
+ let failedTaskScopes = [];
961
1092
  pendingRuntimeDrain = false;
962
1093
  this.#pendingFull = false;
963
1094
  this.#pendingKeys.clear();
@@ -982,12 +1113,25 @@ export class FileTaskController {
982
1113
  }
983
1114
  }
984
1115
  const firstRuntimeDrain = await this.#drainRuntimeEvents();
1116
+ for (const taskId of runtimeTaskFailureIds(firstRuntimeDrain)) {
1117
+ runtimeFailedTaskIds.add(taskId);
1118
+ }
985
1119
  // DurableJob reconciliation runs before the scheduler pass so a
986
1120
  // terminal job's Leader wakeup is enqueued in the same pass that
987
1121
  // processes Leader wakeups.
988
1122
  this.#jobSupervisor?.reconcile(this.#now());
989
- result = await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, scope, false, runtimeCleanupOutcomes, this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer);
1123
+ if (scope.kind === "full") {
1124
+ result = await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, scope, false, runtimeCleanupOutcomes, this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer, runtimeFailedTaskIds);
1125
+ }
1126
+ else {
1127
+ const dirtyPass = await this.#runDirtySchedulerPass(scope, runtimeCleanupOutcomes, runtimeFailedTaskIds);
1128
+ result = dirtyPass.result;
1129
+ failedTaskScopes = dirtyPass.failedTaskScopes;
1130
+ }
990
1131
  const secondRuntimeDrain = await this.#drainRuntimeEvents();
1132
+ for (const taskId of runtimeTaskFailureIds(secondRuntimeDrain)) {
1133
+ runtimeFailedTaskIds.add(taskId);
1134
+ }
991
1135
  pendingRuntimeDrain = ((secondRuntimeDrain?.remainingEventCount ?? 0) > 0
992
1136
  && ((firstRuntimeDrain?.acknowledgedEventIds.length ?? 0) > 0
993
1137
  || (secondRuntimeDrain?.acknowledgedEventIds.length ?? 0) > 0));
@@ -1009,9 +1153,34 @@ export class FileTaskController {
1009
1153
  }
1010
1154
  }
1011
1155
  this.#clearPassRetry();
1156
+ if (scope.kind === "full")
1157
+ this.#clearAllTaskPassRetries();
1012
1158
  this.#scheduleRuntimeCleanupRetries(runtimeCleanupOutcomes);
1013
1159
  this.#scheduleDeliveryRetries(result);
1014
- this.#scheduleTaskMailboxRetries(scope);
1160
+ const failedTaskIds = new Set([
1161
+ ...failedTaskScopes.map((failedScope) => failedScope.taskId),
1162
+ ...runtimeFailedTaskIds
1163
+ ]);
1164
+ this.#scheduleTaskMailboxRetries(scope.kind === "dirty" && failedTaskIds.size > 0
1165
+ ? {
1166
+ kind: "dirty",
1167
+ keys: scope.keys.filter((key) => {
1168
+ const target = parseMailboxKey(key);
1169
+ return (target.kind === "operator"
1170
+ || target.kind === "global-role"
1171
+ || !failedTaskIds.has(target.taskId));
1172
+ })
1173
+ }
1174
+ : scope);
1175
+ for (const failedScope of failedTaskScopes) {
1176
+ this.#clearDeliveryRetry(`task:${encodeURIComponent(failedScope.taskId)}`);
1177
+ this.#scheduleTaskPassRetry(failedScope);
1178
+ }
1179
+ for (const taskId of runtimeFailedTaskIds) {
1180
+ const key = `task:${encodeURIComponent(taskId)}`;
1181
+ this.#clearDeliveryRetry(key);
1182
+ this.#scheduleTaskPassRetry({ taskId, keys: [key] });
1183
+ }
1015
1184
  if (scope.kind === "full"
1016
1185
  || scope.keys.some((key) => key !== "operator")
1017
1186
  || (firstRuntimeDrain?.acknowledgedEventIds.length ?? 0) > 0
@@ -1045,6 +1214,87 @@ export class FileTaskController {
1045
1214
  this.#scheduleNextInputDeadline();
1046
1215
  }
1047
1216
  }
1217
+ async #runDirtySchedulerPass(scope, runtimeCleanupOutcomes, blockedTaskIds = new Set()) {
1218
+ const partition = partitionDirtyScope(scope.keys);
1219
+ const taskScopes = partition.taskScopes.filter((taskScope) => (!blockedTaskIds.has(taskScope.taskId)));
1220
+ const orderedResults = [];
1221
+ if (partition.globalKeys.length > 0) {
1222
+ orderedResults.push(await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, { kind: "dirty", keys: partition.globalKeys }, false, runtimeCleanupOutcomes, this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer, blockedTaskIds));
1223
+ }
1224
+ if (taskScopes.length === 0
1225
+ || this.#stopped
1226
+ || this.#pendingFull) {
1227
+ return {
1228
+ result: mergeControllerSchedulerResults(orderedResults),
1229
+ failedTaskScopes: []
1230
+ };
1231
+ }
1232
+ const queue = new KeyedWorkQueue();
1233
+ this.#dirtyTaskQueue = queue;
1234
+ const scopesByTask = new Map(taskScopes.map((taskScope, index) => [
1235
+ taskScope.taskId,
1236
+ { taskScope, index }
1237
+ ]));
1238
+ const taskResults = Array(taskScopes.length);
1239
+ const taskCleanupOutcomes = Array.from({ length: taskScopes.length }, () => []);
1240
+ const failedTaskScopes = Array(taskScopes.length);
1241
+ const consume = async () => {
1242
+ while (true) {
1243
+ const item = await queue.take();
1244
+ if (item === undefined)
1245
+ return;
1246
+ const selected = scopesByTask.get(item.key);
1247
+ if (selected === undefined) {
1248
+ item.done();
1249
+ throw new Error(`Dirty Task queue returned an unknown Task: ${item.key}.`);
1250
+ }
1251
+ try {
1252
+ if (this.#stopped || this.#pendingFull)
1253
+ continue;
1254
+ try {
1255
+ taskResults[selected.index] = await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, { kind: "dirty", keys: selected.taskScope.keys }, false, taskCleanupOutcomes[selected.index], this.#lifecycleHost, this.#stallWindowMs, this.#maintenanceFence, this.#onMaintenanceFenceDefer, blockedTaskIds);
1256
+ this.#clearTaskPassRetry(selected.taskScope.taskId);
1257
+ }
1258
+ catch (error) {
1259
+ failedTaskScopes[selected.index] = selected.taskScope;
1260
+ this.#onError(error);
1261
+ }
1262
+ }
1263
+ finally {
1264
+ item.done();
1265
+ }
1266
+ }
1267
+ };
1268
+ const consumers = Array.from({
1269
+ length: Math.min(this.#taskConcurrency, taskScopes.length)
1270
+ }, () => consume());
1271
+ for (const taskScope of taskScopes)
1272
+ queue.signal(taskScope.taskId);
1273
+ const stopped = queue.shutdown();
1274
+ if (this.#stopped || this.#pendingFull)
1275
+ void queue.abortPending();
1276
+ try {
1277
+ await Promise.all(consumers);
1278
+ await stopped;
1279
+ }
1280
+ catch (error) {
1281
+ await queue.abortPending();
1282
+ await Promise.allSettled(consumers);
1283
+ throw error;
1284
+ }
1285
+ finally {
1286
+ if (this.#dirtyTaskQueue === queue)
1287
+ this.#dirtyTaskQueue = undefined;
1288
+ }
1289
+ for (const outcomes of taskCleanupOutcomes) {
1290
+ runtimeCleanupOutcomes.push(...outcomes);
1291
+ }
1292
+ orderedResults.push(...taskResults.filter((taskResult) => taskResult !== undefined));
1293
+ return {
1294
+ result: mergeControllerSchedulerResults(orderedResults),
1295
+ failedTaskScopes: failedTaskScopes.filter((failedScope) => failedScope !== undefined)
1296
+ };
1297
+ }
1048
1298
  async #runOperatorPass() {
1049
1299
  const result = await runControllerSchedulerPass(this.store, this.delivery, this.#now(), undefined, { kind: "dirty", keys: ["operator"] });
1050
1300
  if (this.#operatorStartupRetryArmed && result.inputNotifications.length === 0) {
@@ -1087,8 +1337,16 @@ export class FileTaskController {
1087
1337
  this.#runtimeSelectedEvents += metrics.selectedEventCount;
1088
1338
  this.#runtimeProgressEventsCoalesced += metrics.progressEventsCoalesced;
1089
1339
  this.#runtimeStateTransactions += metrics.stateTransactions;
1090
- if (result.failed.length > 0) {
1091
- throw new RuntimeEventApplyError(result.failed.map((failure) => failure.error), "One or more native Turn events could not be applied.");
1340
+ const taskFailures = result.failed.filter((failure) => (failure.scope === "task"
1341
+ && typeof failure.taskId === "string"
1342
+ && failure.taskId.length > 0));
1343
+ const invalidFailures = result.failed.filter((failure) => !(failure.scope === "task"
1344
+ && typeof failure.taskId === "string"
1345
+ && failure.taskId.length > 0));
1346
+ for (const failure of taskFailures)
1347
+ this.#onError(failure.error);
1348
+ if (invalidFailures.length > 0) {
1349
+ throw new RuntimeEventApplyError(invalidFailures.map((failure) => failure.error), "One or more native Turn events could not be applied.");
1092
1350
  }
1093
1351
  return result;
1094
1352
  }
@@ -1113,6 +1371,63 @@ export class FileTaskController {
1113
1371
  this.#passRetryTimer = undefined;
1114
1372
  this.#passRetryAttempt = 0;
1115
1373
  }
1374
+ #scheduleTaskPassRetry(scope) {
1375
+ if (this.#stopped || this.#pendingFull)
1376
+ return;
1377
+ if (this.store.getTask(scope.taskId)?.status !== "active") {
1378
+ this.#clearTaskPassRetry(scope.taskId);
1379
+ return;
1380
+ }
1381
+ const identity = JSON.stringify([...scope.keys].sort((left, right) => (left.localeCompare(right, undefined, { numeric: true }))));
1382
+ let previous = this.#taskPassRetryAttempts.get(scope.taskId);
1383
+ if (previous !== undefined && previous.identity !== identity) {
1384
+ this.#clearTaskPassRetry(scope.taskId);
1385
+ previous = undefined;
1386
+ }
1387
+ if (this.#taskPassRetryTimers.has(scope.taskId))
1388
+ return;
1389
+ const attempts = previous?.attempts ?? 0;
1390
+ if (attempts >= this.#taskOrchestrationRetryLimit) {
1391
+ this.#clearTaskPassRetry(scope.taskId);
1392
+ return;
1393
+ }
1394
+ this.#taskPassRetryAttempts.set(scope.taskId, {
1395
+ identity,
1396
+ attempts: attempts + 1
1397
+ });
1398
+ const delayMs = Math.min(2_000, this.#deliveryRetryMs * (2 ** Math.min(attempts, 3)));
1399
+ const timer = setTimeout(() => {
1400
+ this.#taskPassRetryTimers.delete(scope.taskId);
1401
+ if (this.#stopped || this.#pendingFull)
1402
+ return;
1403
+ try {
1404
+ if (this.store.getTask(scope.taskId)?.status !== "active") {
1405
+ this.#clearTaskPassRetry(scope.taskId);
1406
+ return;
1407
+ }
1408
+ void this.#requestPass({ kind: "dirty", keys: scope.keys }).catch(this.#onError);
1409
+ }
1410
+ catch (error) {
1411
+ this.#clearTaskPassRetry(scope.taskId);
1412
+ this.#onError(error);
1413
+ }
1414
+ }, delayMs);
1415
+ timer.unref();
1416
+ this.#taskPassRetryTimers.set(scope.taskId, timer);
1417
+ }
1418
+ #clearTaskPassRetry(taskId) {
1419
+ const timer = this.#taskPassRetryTimers.get(taskId);
1420
+ if (timer !== undefined)
1421
+ clearTimeout(timer);
1422
+ this.#taskPassRetryTimers.delete(taskId);
1423
+ this.#taskPassRetryAttempts.delete(taskId);
1424
+ }
1425
+ #clearAllTaskPassRetries() {
1426
+ for (const timer of this.#taskPassRetryTimers.values())
1427
+ clearTimeout(timer);
1428
+ this.#taskPassRetryTimers.clear();
1429
+ this.#taskPassRetryAttempts.clear();
1430
+ }
1116
1431
  #scheduleNextInputDeadline() {
1117
1432
  if (this.#deadlineTimer !== undefined) {
1118
1433
  clearTimeout(this.#deadlineTimer);
@@ -1247,7 +1562,7 @@ export class FileTaskController {
1247
1562
  #scheduleTaskMailboxRetries(scope) {
1248
1563
  const selection = compileReconcileSelection(scope);
1249
1564
  const targets = selection.full
1250
- ? this.store.listWorkMailboxes().flatMap((mailbox) => (mailbox.target.kind === "task" ? [mailbox.target] : []))
1565
+ ? selectedReadyWorkMailboxes(this.store).flatMap((mailbox) => (mailbox.target.kind === "task" ? [mailbox.target] : []))
1251
1566
  : [...selection.allRoleTaskIds].map((taskId) => ({ kind: "task", taskId }));
1252
1567
  for (const target of targets) {
1253
1568
  const key = `task:${encodeURIComponent(target.taskId)}`;
@@ -1563,6 +1878,13 @@ function positiveInteger(value, fallback, label) {
1563
1878
  }
1564
1879
  return resolved;
1565
1880
  }
1881
+ function boundedPositiveInteger(value, fallback, maximum, label) {
1882
+ const resolved = positiveInteger(value, fallback, label);
1883
+ if (resolved > maximum) {
1884
+ throw new TypeError(`${label} must be at most ${maximum}`);
1885
+ }
1886
+ return resolved;
1887
+ }
1566
1888
  function operatorMailboxBatchIdentity(mailbox) {
1567
1889
  const batch = mailbox?.pending ?? mailbox?.processing?.batch;
1568
1890
  if (batch === null || batch === undefined)