@zq-silk/yui 0.6.0 → 0.6.2

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.
Files changed (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. package/skills/yui-leader/SKILL.md +8 -0
@@ -1,5 +1,6 @@
1
1
  import { reconciliationIntervalMilliseconds } from "../config/yuiConfig.js";
2
2
  import { processLeaderWakeups } from "../scheduler/leaderWakeupProcessor.js";
3
+ import { pendingWakeupsMatch } from "../scheduler/pendingWakeup.js";
3
4
  import { processActiveRoleRunDeliveries } from "../scheduler/activeRoleRunDelivery.js";
4
5
  import { selectedSchedulerRoles, selectedSchedulerTasks } from "../scheduler/ports.js";
5
6
  import { reconcileExitedRoleRuns } from "../scheduler/roleRunLiveness.js";
@@ -7,28 +8,48 @@ import { DEFAULT_STALL_WINDOW_MS, reconcileStalledRoleRuns } from "../scheduler/
7
8
  import { repairOrphanedActiveTasks } from "../scheduler/activeTaskProgress.js";
8
9
  import { processOperatorInputNotifications } from "../scheduler/operatorInputNotificationProcessor.js";
9
10
  import { startControllerServer } from "../core/controllerServer.js";
11
+ import { monotonicMilliseconds } from "../core/controllerTelemetry.js";
12
+ import { isProjectMaintenanceFenced } from "../repository/projectMaintenanceLock.js";
10
13
  import { MailboxScheduler } from "../coordination/mailboxScheduler.js";
11
14
  import { nearestDeadlineBatch } from "../coordination/deadlineScheduler.js";
12
15
  import { hasRuntimeCleanupObligation, isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
13
16
  import { formatTaskRecordReference } from "../task/taskRecordReference.js";
17
+ import { parseDurableJobAcknowledgeParams, parseDurableJobCancelParams, parseDurableJobRefParams, parseDurableJobStartParams } from "./jobControl.js";
14
18
  const DEFAULT_RECONCILIATION_INTERVAL_MS = reconciliationIntervalMilliseconds();
15
19
  const DEFAULT_SIGNAL_WINDOW_MS = 100;
16
20
  const DEFAULT_DELIVERY_RETRY_MS = 250;
17
21
  const DEFAULT_DELIVERY_RETRY_LIMIT = 60;
22
+ const DEFAULT_TASK_ORCHESTRATION_RETRY_LIMIT = 2;
18
23
  const RUNTIME_RESERVATION_RECOVERY_AGE_MS = 120_000;
19
24
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
25
+ const CONTROLLER_LATENCY_BUCKETS_MS = [10, 50, 100, 250, 500, 1_000, 3_000];
20
26
  class RuntimeEventApplyError extends AggregateError {
21
27
  }
28
+ const ZERO_DRAIN_METRICS = Object.freeze({
29
+ listedEventCount: 0,
30
+ selectedEventCount: 0,
31
+ semanticEventsSelected: 0,
32
+ progressEventsSelected: 0,
33
+ progressEventsCoalesced: 0,
34
+ stateTransactions: 0,
35
+ remainingSemanticEventCount: 0,
36
+ remainingProgressEventCount: 0
37
+ });
22
38
  /**
23
39
  * Runs one lean scheduler pass. Due native Turn completions are folded before
24
40
  * liveness, so a valid Hook boundary fences destructive process reconciliation.
25
41
  */
26
- export async function runControllerSchedulerPass(store, delivery, now, workspacePreparer, scope = { kind: "full" }, includeOperator = true, runtimeCleanupOutcomes = [], lifecycleHost, stallWindowMs = DEFAULT_STALL_WINDOW_MS, resourceSuppressionKeys = new Set()) {
42
+ export async function runControllerSchedulerPass(store, delivery, now, workspacePreparer, scope = { kind: "full" }, includeOperator = true, runtimeCleanupOutcomes = [], lifecycleHost, stallWindowMs = DEFAULT_STALL_WINDOW_MS, resourceSuppressionKeys = new Set(), maintenanceFence, onMaintenanceFenceDefer) {
27
43
  const compiledSelection = compileReconcileSelection(scope);
28
44
  const selection = includeOperator
29
45
  ? compiledSelection
30
46
  : { ...compiledSelection, operator: false };
31
47
  queueSelectedCompletedTaskRuntimeCleanups(store, selection, now);
48
+ // A full-state Task projection can be individually bounded yet still starve
49
+ // control sockets when several scheduler phases repeat it in one native
50
+ // event-loop turn. Give already-written requests a poll boundary before the
51
+ // next durable phase; later phases retain their existing CAS fences.
52
+ await controlEventLoopTurn();
32
53
  const failedCleanupRoles = await processSelectedRoleRuntimeCleanups(store, delivery, lifecycleHost, scope, now, runtimeCleanupOutcomes);
33
54
  const roleSelection = selectionWithoutFailedCleanupRoles(store, selection, failedCleanupRoles);
34
55
  const wakeupSelection = selectionWithoutFailedLeaderCleanupTasks(store, selection, failedCleanupRoles);
@@ -36,49 +57,142 @@ export async function runControllerSchedulerPass(store, delivery, now, workspace
36
57
  repairOrphanedActiveTasks(store, now, selection);
37
58
  const claimedTaskMailboxes = claimSelectedTaskMailboxes(store, selection, now);
38
59
  try {
39
- const failedTaskMailboxes = await prepareActiveWorkspaces(store, workspacePreparer, selection);
60
+ // Durable Leader work that already has a ready Task workspace belongs to
61
+ // the control path. Dispatch it before any unrelated Task workspace I/O;
62
+ // the processor itself retains the fail-closed workspace-ready guard.
63
+ const initialWakeups = selectedPendingWakeups(store, wakeupSelection);
64
+ const initialWakeupResults = await processLeaderWakeups(store, delivery, now, exactTaskSelection(new Set(initialWakeups.keys())));
65
+ // Preserve ready-Leader-first ordering, then bound the repeated state
66
+ // projections that follow it in this pass.
67
+ await controlEventLoopTurn();
68
+ const workspacePreparation = await prepareActiveWorkspaces(store, workspacePreparer, selection, maintenanceFence, onMaintenanceFenceDefer);
69
+ await controlEventLoopTurn();
70
+ // Issue 04: reopen due in-place Provider retries on their original
71
+ // Sessions before delivery, so the existing delivery path re-pushes the
72
+ // exact same input in this same pass.
73
+ resolveDueProviderRetries(store, roleSelection, now);
40
74
  const activeRunDeliveries = await processActiveRoleRunDeliveries(store, delivery, now, roleSelection);
75
+ await controlEventLoopTurn();
41
76
  const unsettledRunRefs = new Set(activeRunDeliveries.flatMap((result) => (result.reason === "delivery-uncertain" || result.terminalFailure !== undefined
42
77
  ? [formatTaskRecordReference(result.taskId, result.runId, "agentRun")]
43
78
  : [])));
44
79
  resolveDueRuntimeTurnCompletions(store, delivery, selection, now);
80
+ // Every successful Task has completed the targeted phases owned by its
81
+ // mailbox at this boundary. Settle that exact claim before advisory
82
+ // cross-Task reconciliation so an unrelated failure cannot retain and
83
+ // retry already-progressed work.
84
+ for (const claim of claimedTaskMailboxes) {
85
+ if (!workspacePreparation.failed.has(claim.target.taskId)) {
86
+ store.completeWorkMailbox(claim.target, claim.processing.batchId);
87
+ }
88
+ }
89
+ const newlyIdleBusyTaskIds = new Set(initialWakeupResults.flatMap((result) => (result.reason === "busy"
90
+ && store.getActiveAgentRun(result.taskId, "leader") === null
91
+ && (typeof store.hasInFlightTurn !== "function"
92
+ || !store.hasInFlightTurn(result.taskId, "leader"))
93
+ ? [result.taskId]
94
+ : [])));
95
+ // Phase-one Leader Runs did not exist at the pass's liveness boundary.
96
+ // Keep every newly claimed Run outside destructive absence decisions until
97
+ // a later pass can observe its stable provider/session generation.
98
+ for (const result of initialWakeupResults) {
99
+ if (result.runId !== undefined
100
+ && store.getActiveAgentRun(result.taskId, "leader")?.id === result.runId) {
101
+ unsettledRunRefs.add(formatTaskRecordReference(result.taskId, result.runId, "agentRun"));
102
+ }
103
+ }
104
+ // Let socket callbacks queued during the bounded scheduler phases run
105
+ // before starting a potentially large liveness inventory.
106
+ await controlEventLoopTurn();
45
107
  const liveStatuses = new Map();
46
108
  const resourceEvidence = new Map();
47
109
  const failedRunRefs = await reconcileExitedRoleRuns(store, delivery, now, roleSelection, unsettledRunRefs, liveStatuses, resourceEvidence);
110
+ await controlEventLoopTurn();
48
111
  await reconcileStalledRoleRuns(store, delivery, now, roleSelection, stallWindowMs, liveStatuses, resourceEvidence, resourceSuppressionKeys);
112
+ await controlEventLoopTurn();
49
113
  await reconcileDormantRuntimeOwners(store, delivery, lifecycleHost, scope, now);
50
114
  const autoResolvedInputs = selection.full
51
115
  ? store.resolveExpiredInputRecommendations(now)
52
116
  : selection.taskIds.size === 0
53
117
  ? []
54
118
  : store.resolveExpiredInputRecommendations(now, selection.taskIds);
55
- const wakeups = await processLeaderWakeups(store, delivery, now, wakeupSelection);
119
+ // Liveness and auto-resolution can durably queue new Leader work. Process
120
+ // only Tasks that were not part of phase one, except when a same-pass due
121
+ // completion made an initially busy Leader idle. Result order remains
122
+ // deterministic and every other retained/busy wake stays single-shot.
123
+ const autoResolvedTaskIds = new Set(autoResolvedInputs.map(({ taskId }) => taskId));
124
+ const waitingInputTaskIds = new Set(initialWakeupResults.flatMap((result) => (result.reason === "waiting-input" && autoResolvedTaskIds.has(result.taskId)
125
+ ? [result.taskId]
126
+ : [])));
127
+ const workspaceReadyTaskIds = new Set(initialWakeupResults.flatMap((result) => (result.reason === "workspace-not-ready"
128
+ && workspacePreparation.ready.has(result.taskId)
129
+ ? [result.taskId]
130
+ : [])));
131
+ const laterWakeupTaskIds = new Set([...selectedPendingWakeups(store, wakeupSelection)].flatMap(([taskId, wakeup]) => {
132
+ const initial = initialWakeups.get(taskId);
133
+ return initial === undefined
134
+ || !pendingWakeupsMatch(initial, wakeup)
135
+ || waitingInputTaskIds.has(taskId)
136
+ || workspaceReadyTaskIds.has(taskId)
137
+ || newlyIdleBusyTaskIds.has(taskId)
138
+ ? [taskId]
139
+ : [];
140
+ }));
141
+ const laterWakeups = await processLeaderWakeups(store, delivery, now, exactTaskSelection(laterWakeupTaskIds));
56
142
  const inputNotifications = includeOperator
57
143
  ? await processOperatorInputNotifications(store, delivery, selection, now)
58
144
  : [];
59
- for (const claim of claimedTaskMailboxes) {
60
- if (failedTaskMailboxes.has(claim.target.taskId)) {
61
- store.releaseWorkMailbox(claim.target, claim.processing.batchId);
62
- }
63
- else {
64
- store.completeWorkMailbox(claim.target, claim.processing.batchId);
65
- }
66
- }
67
145
  return {
68
146
  activeRunDeliveries,
69
147
  failedRunRefs,
70
- wakeups,
148
+ wakeups: mergeWakeupPhaseResults(initialWakeupResults, laterWakeups),
71
149
  inputNotifications,
72
150
  autoResolvedInputs
73
151
  };
74
152
  }
75
153
  catch (error) {
76
- for (const claim of claimedTaskMailboxes) {
77
- store.releaseWorkMailbox(claim.target, claim.processing.batchId);
78
- }
154
+ // Task orchestration retries are owned by this Controller generation.
155
+ // Preserve each exact processing claim in place; releasing here would
156
+ // rewrite the full aggregate and then immediately claim the same batch.
79
157
  throw error;
80
158
  }
81
159
  }
160
+ function selectedPendingWakeups(store, selection) {
161
+ const wakeups = selection.full
162
+ ? store.listPendingWakeups()
163
+ : [...selection.taskIds].flatMap((taskId) => {
164
+ const wakeup = store.getPendingWakeup(taskId);
165
+ return wakeup === null ? [] : [wakeup];
166
+ });
167
+ return new Map(wakeups.map((wakeup) => [
168
+ wakeup.taskId,
169
+ { ...wakeup, reasons: [...wakeup.reasons] }
170
+ ]));
171
+ }
172
+ function exactTaskSelection(taskIds) {
173
+ return {
174
+ full: false,
175
+ taskIds,
176
+ allRoleTaskIds: new Set(),
177
+ rolesByTask: new Map(),
178
+ operator: false
179
+ };
180
+ }
181
+ function mergeWakeupPhaseResults(initial, later) {
182
+ const merged = [...initial];
183
+ const positions = new Map(initial.map(({ taskId }, index) => [taskId, index]));
184
+ for (const result of later) {
185
+ const position = positions.get(result.taskId);
186
+ if (position === undefined) {
187
+ positions.set(result.taskId, merged.length);
188
+ merged.push(result);
189
+ }
190
+ else {
191
+ merged[position] = result;
192
+ }
193
+ }
194
+ return merged;
195
+ }
82
196
  function queueSelectedCompletedTaskRuntimeCleanups(store, selection, now) {
83
197
  if (store.enqueueRuntimeCleanup === undefined)
84
198
  return;
@@ -289,6 +403,20 @@ function resolveDueRuntimeTurnCompletions(store, delivery, selection, now) {
289
403
  });
290
404
  }
291
405
  }
406
+ /**
407
+ * Issue 04: reopens due in-place Provider retries on their original Native
408
+ * Sessions before the active-run delivery pass, so the existing delivery
409
+ * path re-pushes the exact same input in the same pass. A Run whose Session
410
+ * is proven dead terminalizes with a replacement blocker instead.
411
+ */
412
+ function resolveDueProviderRetries(store, selection, now) {
413
+ if (typeof store.resolveDueProviderRetries !== "function")
414
+ return;
415
+ const selectedTaskIds = selection.full ? undefined : selection.taskIds;
416
+ if (selectedTaskIds?.size === 0)
417
+ return;
418
+ store.resolveDueProviderRetries(now, selectedTaskIds);
419
+ }
292
420
  function selectedRuntimeLifecycleTargets(store, scope) {
293
421
  if (scope.kind === "full") {
294
422
  return store.listWorkMailboxes().flatMap((mailbox) => (mailbox.target.kind === "role-runtime"
@@ -467,28 +595,44 @@ export function compileReconcileSelection(scope) {
467
595
  operator
468
596
  };
469
597
  }
470
- async function prepareActiveWorkspaces(store, workspace, selection) {
598
+ async function prepareActiveWorkspaces(store, workspace, selection, maintenanceFence, onMaintenanceFenceDefer) {
471
599
  if (workspace === undefined)
472
- return new Set();
600
+ return { failed: new Set(), ready: new Set() };
473
601
  const taskIds = selection.full
474
602
  ? new Set(store.listTasks()
475
603
  .filter((task) => task.status === "active")
476
604
  .map((task) => task.id))
477
- : new Set([...selection.taskIds, ...selection.allRoleTaskIds]);
605
+ : new Set(selection.allRoleTaskIds);
478
606
  const failed = new Set();
607
+ const ready = new Set();
479
608
  for (const taskId of taskIds) {
480
- if (store.getTask(taskId)?.status === "active") {
609
+ const task = store.getTask(taskId);
610
+ if (task?.status === "active") {
611
+ // A Project under maintenance is fenced: defer this Task's preparation
612
+ // for the pass. The deferral is per-Project, never a Controller stop,
613
+ // and a deferred Task is not marked failed.
614
+ if (maintenanceFence !== undefined) {
615
+ const fencedProjects = task.projectBindings
616
+ .map(({ projectId }) => projectId)
617
+ .filter((projectId) => maintenanceFence(projectId));
618
+ if (fencedProjects.length > 0) {
619
+ onMaintenanceFenceDefer?.({ taskId, projectIds: fencedProjects });
620
+ continue;
621
+ }
622
+ }
481
623
  try {
482
624
  const result = await workspace.prepareTaskWorkspace(taskId);
483
625
  if (result.status === "failed")
484
626
  failed.add(taskId);
627
+ else
628
+ ready.add(taskId);
485
629
  }
486
630
  catch {
487
631
  failed.add(taskId);
488
632
  }
489
633
  }
490
634
  }
491
- return failed;
635
+ return { failed, ready };
492
636
  }
493
637
  function parseMailboxKey(key) {
494
638
  if (key === "operator")
@@ -544,6 +688,7 @@ export class FileTaskController {
544
688
  #workspacePreparer;
545
689
  #deliveryRetryMs;
546
690
  #deliveryRetryLimit;
691
+ #taskOrchestrationRetryLimit;
547
692
  #stallWindowMs;
548
693
  /** Narrow-port fallback; FileSchedulerStoreAdapter durably records these keys. */
549
694
  #resourceSuppressionKeys = new Set();
@@ -559,7 +704,11 @@ export class FileTaskController {
559
704
  #operatorSignalScheduler;
560
705
  #configuration;
561
706
  #resourceReaper;
707
+ #resourceAutoGc;
562
708
  #onExpiredEphemeralDomain;
709
+ #maintenanceFence;
710
+ #onMaintenanceFenceDefer;
711
+ #jobSupervisor;
563
712
  #current;
564
713
  #operatorCurrent;
565
714
  #pendingFull = false;
@@ -567,6 +716,12 @@ export class FileTaskController {
567
716
  #operatorStartupRetryArmed = false;
568
717
  #lastOperatorSignalIdentity;
569
718
  #stopped = false;
719
+ #lastRuntimeDrain;
720
+ #runtimeDrainPasses = 0;
721
+ #runtimeListedEvents = 0;
722
+ #runtimeSelectedEvents = 0;
723
+ #runtimeProgressEventsCoalesced = 0;
724
+ #runtimeStateTransactions = 0;
570
725
  constructor(store, delivery, options = {}) {
571
726
  this.store = store;
572
727
  this.delivery = delivery;
@@ -576,12 +731,17 @@ export class FileTaskController {
576
731
  this.#workspacePreparer = options.workspacePreparer;
577
732
  this.#deliveryRetryMs = positiveInteger(options.deliveryRetryMs, DEFAULT_DELIVERY_RETRY_MS, "Controller delivery retry delay");
578
733
  this.#deliveryRetryLimit = positiveInteger(options.deliveryRetryLimit, DEFAULT_DELIVERY_RETRY_LIMIT, "Controller delivery retry limit");
734
+ this.#taskOrchestrationRetryLimit = positiveInteger(options.taskOrchestrationRetryLimit, DEFAULT_TASK_ORCHESTRATION_RETRY_LIMIT, "Controller Task orchestration retry limit");
579
735
  this.#stallWindowMs = positiveInteger(options.stallWindowMs, DEFAULT_STALL_WINDOW_MS, "Controller Run stall window");
580
736
  this.#runtimeEventProcessor = options.runtimeEventProcessor;
581
737
  this.#lifecycleHost = options.lifecycleHost;
582
738
  this.#configuration = options.configuration;
583
739
  this.#resourceReaper = options.resourceReaper;
740
+ this.#resourceAutoGc = options.resourceAutoGc;
584
741
  this.#onExpiredEphemeralDomain = options.onExpiredEphemeralDomain;
742
+ this.#maintenanceFence = options.maintenanceFence;
743
+ this.#onMaintenanceFenceDefer = options.onMaintenanceFenceDefer;
744
+ this.#jobSupervisor = options.jobSupervisor;
585
745
  this.#signalScheduler = new MailboxScheduler(async (keys) => { await this.#requestPass({ kind: "dirty", keys }); }, {
586
746
  windowMs: options.signalWindowMs ?? DEFAULT_SIGNAL_WINDOW_MS,
587
747
  onError: this.#onError,
@@ -614,6 +774,25 @@ export class FileTaskController {
614
774
  get reconciliationIntervalMs() {
615
775
  return this.#intervalMs;
616
776
  }
777
+ runtimeMetrics() {
778
+ const metrics = this.#lastRuntimeDrain?.metrics ?? ZERO_DRAIN_METRICS;
779
+ return {
780
+ // Status is intentionally O(1): scanning the inbox while serving the
781
+ // control socket would recreate the starvation this metric diagnoses.
782
+ inbox: {
783
+ depth: this.#lastRuntimeDrain?.remainingEventCount ?? 0,
784
+ semanticDepth: metrics.remainingSemanticEventCount,
785
+ progressDepth: metrics.remainingProgressEventCount
786
+ },
787
+ drain: {
788
+ passes: this.#runtimeDrainPasses,
789
+ listedEvents: this.#runtimeListedEvents,
790
+ selectedEvents: this.#runtimeSelectedEvents,
791
+ progressEventsCoalesced: this.#runtimeProgressEventsCoalesced,
792
+ stateTransactions: this.#runtimeStateTransactions
793
+ }
794
+ };
795
+ }
617
796
  updateReconciliationInterval(intervalMs) {
618
797
  const next = positiveInteger(intervalMs, DEFAULT_RECONCILIATION_INTERVAL_MS, "Controller reconciliation interval");
619
798
  if (this.#stopped)
@@ -740,12 +919,14 @@ export class FileTaskController {
740
919
  inputNotifications: [],
741
920
  autoResolvedInputs: []
742
921
  };
922
+ let pendingRuntimeDrain = false;
743
923
  try {
744
- while (this.#pendingFull || this.#pendingKeys.size > 0) {
924
+ while (this.#pendingFull || this.#pendingKeys.size > 0 || pendingRuntimeDrain) {
745
925
  const scope = this.#pendingFull
746
926
  ? { kind: "full" }
747
927
  : { kind: "dirty", keys: [...this.#pendingKeys] };
748
928
  const runtimeCleanupOutcomes = [];
929
+ pendingRuntimeDrain = false;
749
930
  this.#pendingFull = false;
750
931
  this.#pendingKeys.clear();
751
932
  try {
@@ -769,8 +950,32 @@ export class FileTaskController {
769
950
  }
770
951
  }
771
952
  const firstRuntimeDrain = await this.#drainRuntimeEvents();
772
- result = await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, scope, false, runtimeCleanupOutcomes, this.#lifecycleHost, this.#stallWindowMs, this.#resourceSuppressionKeys);
953
+ // DurableJob reconciliation runs before the scheduler pass so a
954
+ // terminal job's Leader wakeup is enqueued in the same pass that
955
+ // processes Leader wakeups.
956
+ this.#jobSupervisor?.reconcile(this.#now());
957
+ result = await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, scope, false, runtimeCleanupOutcomes, this.#lifecycleHost, this.#stallWindowMs, this.#resourceSuppressionKeys, this.#maintenanceFence, this.#onMaintenanceFenceDefer);
773
958
  const secondRuntimeDrain = await this.#drainRuntimeEvents();
959
+ pendingRuntimeDrain = ((secondRuntimeDrain?.remainingEventCount ?? 0) > 0
960
+ && ((firstRuntimeDrain?.acknowledgedEventIds.length ?? 0) > 0
961
+ || (secondRuntimeDrain?.acknowledgedEventIds.length ?? 0) > 0));
962
+ // Issue 10: automatic Resource GC runs only on full passes, after
963
+ // the scheduler pass and both runtime drains have settled Task
964
+ // terminal state. It is default-off and self-skipping; a failed
965
+ // pass is logged and retried next time, never breaking the
966
+ // scheduler.
967
+ if (scope.kind === "full" && this.#resourceAutoGc !== undefined) {
968
+ try {
969
+ const gc = await this.#resourceAutoGc();
970
+ if (!gc.skipped && gc.failed > 0) {
971
+ this.#onError(new Error(`Resource auto-GC failed to quarantine ${gc.failed} `
972
+ + "resource(s); they stay in place and retry next pass."));
973
+ }
974
+ }
975
+ catch (error) {
976
+ this.#onError(error);
977
+ }
978
+ }
774
979
  this.#clearPassRetry();
775
980
  this.#scheduleRuntimeCleanupRetries(runtimeCleanupOutcomes);
776
981
  this.#scheduleDeliveryRetries(result);
@@ -796,6 +1001,11 @@ export class FileTaskController {
796
1001
  }
797
1002
  if (this.#stopped)
798
1003
  break;
1004
+ if (this.#pendingFull || this.#pendingKeys.size > 0 || pendingRuntimeDrain) {
1005
+ // A continuous Hook signal stream must yield to socket callbacks
1006
+ // between bounded scheduler passes.
1007
+ await eventLoopTurn();
1008
+ }
799
1009
  }
800
1010
  return result;
801
1011
  }
@@ -838,6 +1048,13 @@ export class FileTaskController {
838
1048
  const result = "drainAsync" in processor
839
1049
  ? await processor.drainAsync(this.#now())
840
1050
  : processor.drain(this.#now());
1051
+ this.#lastRuntimeDrain = result;
1052
+ this.#runtimeDrainPasses += 1;
1053
+ const metrics = result.metrics ?? ZERO_DRAIN_METRICS;
1054
+ this.#runtimeListedEvents += metrics.listedEventCount;
1055
+ this.#runtimeSelectedEvents += metrics.selectedEventCount;
1056
+ this.#runtimeProgressEventsCoalesced += metrics.progressEventsCoalesced;
1057
+ this.#runtimeStateTransactions += metrics.stateTransactions;
841
1058
  if (result.failed.length > 0) {
842
1059
  throw new RuntimeEventApplyError(result.failed.map((failure) => failure.error), "One or more native Turn events could not be applied.");
843
1060
  }
@@ -884,6 +1101,15 @@ export class FileTaskController {
884
1101
  : []).map((completion) => ({
885
1102
  key: `role:${encodeURIComponent(completion.taskId)}/${encodeURIComponent(completion.roleName)}`,
886
1103
  at: Date.parse(completion.dueAt)
1104
+ })),
1105
+ // Issue 04 durable in-place retry timer: arm the Controller wake at the
1106
+ // earliest `nextAttemptAt` so a due retry re-pushes on its original
1107
+ // Session. The projection is durable, so a restart resumes the lineage.
1108
+ ...(typeof this.store.listPendingProviderRetries === "function"
1109
+ ? this.store.listPendingProviderRetries()
1110
+ : []).map((retry) => ({
1111
+ key: `role:${encodeURIComponent(retry.taskId)}/${encodeURIComponent(retry.roleName)}`,
1112
+ at: Date.parse(retry.nextAttemptAt)
887
1113
  }))
888
1114
  ];
889
1115
  const nearest = nearestDeadlineBatch(deadlines);
@@ -986,7 +1212,9 @@ export class FileTaskController {
986
1212
  for (const target of targets) {
987
1213
  const key = `task:${encodeURIComponent(target.taskId)}`;
988
1214
  const mailbox = this.store.getWorkMailbox(target);
989
- const batch = mailbox?.pending ?? mailbox?.processing?.batch;
1215
+ // A newly queued pending batch must not reset the retry identity/bound
1216
+ // while this Controller still owns an older processing batch.
1217
+ const batch = mailbox?.processing?.batch ?? mailbox?.pending;
990
1218
  if (batch === undefined || batch === null) {
991
1219
  this.#clearDeliveryRetry(key);
992
1220
  continue;
@@ -1011,7 +1239,10 @@ export class FileTaskController {
1011
1239
  return;
1012
1240
  }
1013
1241
  const attempts = previous?.attempts ?? 0;
1014
- if (attempts >= this.#deliveryRetryLimit) {
1242
+ const retryLimit = key.startsWith("task:")
1243
+ ? this.#taskOrchestrationRetryLimit
1244
+ : this.#deliveryRetryLimit;
1245
+ if (attempts >= retryLimit) {
1015
1246
  if (key === "operator")
1016
1247
  this.#operatorStartupRetryArmed = false;
1017
1248
  this.#terminalizePreparedAfterRetryExhaustion(key, identity, stableTerminalFailure);
@@ -1090,44 +1321,100 @@ export class FileTaskController {
1090
1321
  * layer adds scheduler.signal/scheduler.scan plus an optional command dispatcher.
1091
1322
  */
1092
1323
  export async function startFileTaskController(home, store, delivery, dispatcher, options = {}) {
1093
- const runtime = new FileTaskController(store, delivery, options);
1324
+ // The Controller defers preparation for a Project while its maintenance
1325
+ // fence is held, so migrate/rebuild/archive/cleanup never interleave with
1326
+ // worktree creation. Callers may override the predicate (e.g. tests).
1327
+ const runtime = new FileTaskController(store, delivery, {
1328
+ ...options,
1329
+ maintenanceFence: options.maintenanceFence
1330
+ ?? ((projectId) => isProjectMaintenanceFenced(home, projectId))
1331
+ });
1094
1332
  let stopping = false;
1095
1333
  const lifecycleRequests = new Set();
1334
+ const dispatcherServiceTime = new ControllerDispatcherServiceTimeMetrics();
1096
1335
  const server = await startControllerServer(home, async (method, params) => {
1097
- if (stopping) {
1098
- throw controllerApplicationError("METHOD_NOT_FOUND", "Controller is stopping.");
1099
- }
1100
- if (method === "scheduler.signal") {
1101
- runtime.signal(signalMailboxKey(params));
1102
- return { accepted: true };
1103
- }
1104
- if (method === "scheduler.scan") {
1105
- if (!isEmptyJsonObject(params)) {
1106
- throw controllerApplicationError("INVALID_PARAMS", "scheduler.scan params are invalid.");
1107
- }
1108
- return schedulerResultJson(await runtime.pump());
1109
- }
1110
- if (method === "scheduler.configure") {
1111
- requireEmptySchedulerConfigureParams(params);
1112
- const intervalMs = runtime.reloadReconciliationInterval();
1113
- return { configured: true, reconciliationIntervalMs: intervalMs };
1114
- }
1115
- if (dispatcher === undefined) {
1116
- throw controllerApplicationError("METHOD_NOT_FOUND", "Controller method was not found.");
1117
- }
1118
- const request = Promise.resolve(dispatcher(method, params));
1119
- lifecycleRequests.add(request);
1336
+ const startedAt = monotonicMilliseconds();
1337
+ dispatcherServiceTime.started();
1120
1338
  try {
1121
- const result = await request;
1122
- if (method === "runtime.ensure-role-session"
1123
- && isGlobalOperatorSessionRequest(params)
1124
- && isStartedRuntimeSessionResult(result)) {
1125
- runtime.armOperatorStartupRetry();
1339
+ if (stopping) {
1340
+ throw controllerApplicationError("METHOD_NOT_FOUND", "Controller is stopping.");
1341
+ }
1342
+ if (method === "scheduler.signal") {
1343
+ runtime.signal(signalMailboxKey(params));
1344
+ return { accepted: true };
1345
+ }
1346
+ if (method === "scheduler.scan") {
1347
+ if (!isEmptyJsonObject(params)) {
1348
+ throw controllerApplicationError("INVALID_PARAMS", "scheduler.scan params are invalid.");
1349
+ }
1350
+ return schedulerResultJson(await runtime.pump());
1351
+ }
1352
+ if (method === "scheduler.configure") {
1353
+ requireEmptySchedulerConfigureParams(params);
1354
+ const intervalMs = runtime.reloadReconciliationInterval();
1355
+ return { configured: true, reconciliationIntervalMs: intervalMs };
1356
+ }
1357
+ if (method === "job.start" || method === "job.get" || method === "job.cancel" || method === "job.acknowledge") {
1358
+ const control = options.jobControl;
1359
+ if (control === undefined) {
1360
+ throw controllerApplicationError("METHOD_NOT_FOUND", "Controller method was not found.");
1361
+ }
1362
+ if (method === "job.start") {
1363
+ const input = parseDurableJobStartParams(params);
1364
+ const { job, created } = control.startJob(input, new Date());
1365
+ if (created)
1366
+ runtime.signal(`task:${job.taskId}`);
1367
+ return jobControlJson({ job, created });
1368
+ }
1369
+ if (method === "job.acknowledge") {
1370
+ // rr26: acknowledge uses the same ephemeral task Session caller key
1371
+ // as start/cancel; a replayable assertion alone is not authority.
1372
+ const { taskId, jobId, caller } = parseDurableJobAcknowledgeParams(params);
1373
+ const job = control.acknowledgeJob(taskId, jobId, new Date(), caller);
1374
+ if (job === null) {
1375
+ throw controllerApplicationError("NOT_FOUND", `DurableJob not found: ${taskId}/${jobId}.`);
1376
+ }
1377
+ runtime.signal(`task:${job.taskId}`);
1378
+ return jobControlJson({ job, acknowledged: job.acknowledgedAt !== undefined });
1379
+ }
1380
+ if (method === "job.get") {
1381
+ const ref = parseDurableJobRefParams(params);
1382
+ const job = control.getJob(ref.taskId, ref.jobId);
1383
+ if (job === null) {
1384
+ throw controllerApplicationError("NOT_FOUND", `DurableJob not found: ${ref.taskId}/${ref.jobId}.`);
1385
+ }
1386
+ return jobControlJson({ job });
1387
+ }
1388
+ // rr8: job.cancel carries the caller identity so the control port can
1389
+ // bind the cancel request to the caller's managed scope.
1390
+ const cancel = parseDurableJobCancelParams(params);
1391
+ const job = control.cancelJob(cancel.taskId, cancel.jobId, new Date(), cancel.caller);
1392
+ if (job === null) {
1393
+ throw controllerApplicationError("NOT_FOUND", `DurableJob not found: ${cancel.taskId}/${cancel.jobId}.`);
1394
+ }
1395
+ runtime.signal(`task:${job.taskId}`);
1396
+ return jobControlJson({ job, cancelRequested: job.cancelRequestedAt !== undefined });
1397
+ }
1398
+ if (dispatcher === undefined) {
1399
+ throw controllerApplicationError("METHOD_NOT_FOUND", "Controller method was not found.");
1400
+ }
1401
+ const request = Promise.resolve(dispatcher(method, params));
1402
+ lifecycleRequests.add(request);
1403
+ try {
1404
+ const result = await request;
1405
+ if (method === "runtime.ensure-role-session"
1406
+ && isGlobalOperatorSessionRequest(params)
1407
+ && isStartedRuntimeSessionResult(result)) {
1408
+ runtime.armOperatorStartupRetry();
1409
+ }
1410
+ return result;
1411
+ }
1412
+ finally {
1413
+ lifecycleRequests.delete(request);
1126
1414
  }
1127
- return result;
1128
1415
  }
1129
1416
  finally {
1130
- lifecycleRequests.delete(request);
1417
+ dispatcherServiceTime.completed(monotonicMilliseconds() - startedAt);
1131
1418
  }
1132
1419
  }, async () => {
1133
1420
  stopping = true;
@@ -1135,7 +1422,15 @@ export async function startFileTaskController(home, store, delivery, dispatcher,
1135
1422
  await Promise.allSettled([...lifecycleRequests]);
1136
1423
  await runtime.shutdownAndDrain();
1137
1424
  }, {
1138
- domainIdentity: options.domainIdentity
1425
+ domainIdentity: options.domainIdentity,
1426
+ status: ({ commandObserver, eventLoopDelay }) => ({
1427
+ ...runtime.runtimeMetrics(),
1428
+ commands: {
1429
+ dispatcher: dispatcherServiceTime.snapshot(),
1430
+ routes: commandObserver.snapshot(),
1431
+ eventLoopDelay: eventLoopDelay.snapshot()
1432
+ }
1433
+ })
1139
1434
  });
1140
1435
  runtime.start();
1141
1436
  const closed = server.closed;
@@ -1150,6 +1445,53 @@ export async function startFileTaskController(home, store, delivery, dispatcher,
1150
1445
  }
1151
1446
  };
1152
1447
  }
1448
+ /**
1449
+ * Measures dispatcher service time only: the elapsed time inside the FileTask
1450
+ * dispatcher from dispatch to completion. It deliberately excludes the
1451
+ * socket/event-loop wait before routing, which the core server's event-loop
1452
+ * delay telemetry observes separately.
1453
+ */
1454
+ class ControllerDispatcherServiceTimeMetrics {
1455
+ #completed = 0;
1456
+ #inFlight = 0;
1457
+ #maximumServiceTimeMs = 0;
1458
+ #buckets = new Map(CONTROLLER_LATENCY_BUCKETS_MS.map((threshold) => [threshold, 0]));
1459
+ started() {
1460
+ this.#inFlight += 1;
1461
+ }
1462
+ completed(serviceTimeMs) {
1463
+ this.#inFlight = Math.max(0, this.#inFlight - 1);
1464
+ this.#completed += 1;
1465
+ const bounded = Math.max(0, Math.ceil(serviceTimeMs));
1466
+ this.#maximumServiceTimeMs = Math.max(this.#maximumServiceTimeMs, bounded);
1467
+ for (const threshold of CONTROLLER_LATENCY_BUCKETS_MS) {
1468
+ if (bounded <= threshold) {
1469
+ this.#buckets.set(threshold, (this.#buckets.get(threshold) ?? 0) + 1);
1470
+ }
1471
+ }
1472
+ }
1473
+ snapshot() {
1474
+ return {
1475
+ completed: this.#completed,
1476
+ inFlight: this.#inFlight,
1477
+ maximumServiceTimeMs: this.#maximumServiceTimeMs,
1478
+ serviceTimeBuckets: Object.fromEntries(CONTROLLER_LATENCY_BUCKETS_MS.map((threshold) => [
1479
+ `le${threshold}ms`,
1480
+ this.#buckets.get(threshold) ?? 0
1481
+ ]))
1482
+ };
1483
+ }
1484
+ }
1485
+ function eventLoopTurn() {
1486
+ return new Promise((resolve) => setImmediate(resolve));
1487
+ }
1488
+ async function controlEventLoopTurn() {
1489
+ // A setImmediate scheduled from the check phase may resume before the next
1490
+ // poll phase. Two turns guarantee already-written socket data gets one poll
1491
+ // opportunity before another synchronous scheduler projection starts.
1492
+ await eventLoopTurn();
1493
+ await eventLoopTurn();
1494
+ }
1153
1495
  function signalMailboxKey(value) {
1154
1496
  if (typeof value !== "object"
1155
1497
  || value === null
@@ -1219,3 +1561,7 @@ function controllerApplicationError(code, message) {
1219
1561
  error.name = "CoreApplicationError";
1220
1562
  return error;
1221
1563
  }
1564
+ /** DurableJob records are validated plain data; normalize to a JsonValue. */
1565
+ function jobControlJson(value) {
1566
+ return JSON.parse(JSON.stringify(value));
1567
+ }