From 23172cf1367f513d3bb7ad81878bba15cb69a315 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 18:28:06 -0700 Subject: [PATCH 1/3] [rush-daemon] Apply --verbose/--parallelism per request instead of reloading the warm graph Presentation and scheduling flags (--verbose, --parallelism, --timeline) are no longer part of the engine parameter identity, so they no longer force a tier-1 reload that discards all retained results. rushd applies quietMode/parallelism to the shared graph before each iteration and only coalesces requests with equal settings. Fixes #6048 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../rush/flag-reload_2026-09-24.json | 11 ++ .../rush-daemon/flag-reload_2026-09-24.json | 11 ++ .../src/DaemonRequestDispatcher.ts | 6 +- .../rush-daemon/src/PhasedRequestRouter.ts | 30 ++++- .../src/ProductionDaemonRequestResolver.ts | 5 +- .../WorkspaceRequestScopedParameters.test.ts | 52 +++++++++ .../rush-lib/src/api/PhasedCommandEngine.ts | 17 +++ ...asedCommandEngineParameterIdentity.test.ts | 110 ++++++++++++++++++ .../cli/scriptActions/PhasedScriptAction.ts | 31 ++++- libraries/rush-lib/src/index.ts | 1 + 10 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 common/changes/@microsoft/rush/flag-reload_2026-09-24.json create mode 100644 common/changes/@rushstack/rush-daemon/flag-reload_2026-09-24.json create mode 100644 libraries/rush-daemon/src/test/WorkspaceRequestScopedParameters.test.ts create mode 100644 libraries/rush-lib/src/api/test/PhasedCommandEngineParameterIdentity.test.ts diff --git a/common/changes/@microsoft/rush/flag-reload_2026-09-24.json b/common/changes/@microsoft/rush/flag-reload_2026-09-24.json new file mode 100644 index 0000000000..705c6b3397 --- /dev/null +++ b/common/changes/@microsoft/rush/flag-reload_2026-09-24.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Exclude `--verbose`, `--parallelism` and `--timeline` from the daemon engine parameter identity and expose them as per-request settings, so these flags no longer force the Rush daemon to reload its warm operation graph.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-daemon/flag-reload_2026-09-24.json b/common/changes/@rushstack/rush-daemon/flag-reload_2026-09-24.json new file mode 100644 index 0000000000..4c774a0912 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/flag-reload_2026-09-24.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Apply `--verbose` and `--parallelism` to the warm graph per iteration instead of reloading the graph, and only coalesce requests that share these settings.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-daemon", + "email": "selarkin@microsoft.com" +} diff --git a/libraries/rush-daemon/src/DaemonRequestDispatcher.ts b/libraries/rush-daemon/src/DaemonRequestDispatcher.ts index 38f8d53e7f..e78e631731 100644 --- a/libraries/rush-daemon/src/DaemonRequestDispatcher.ts +++ b/libraries/rush-daemon/src/DaemonRequestDispatcher.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import type { IPhasedCommandEngineRequestSettings } from '@microsoft/rush-lib'; import type { IDaemonCommandResult, IDaemonEventEnvelope, @@ -32,6 +33,8 @@ export interface IResolvedDaemonPhasedRequest { readonly request: IDaemonPhasedRequest; /** Native selection has already resolved all required project/phase dependencies. */ readonly exactSelection?: boolean; + /** Verbosity and parallelism for this request; applied to the shared graph before its iteration. */ + readonly requestSettings?: IPhasedCommandEngineRequestSettings; } /** A resolver outcome that uses the existing isolated global executor contract. @beta */ @@ -187,7 +190,8 @@ async function dispatchWorkspaceRequestAsync( resolved.request, createPhasedClient(client), resolved.exactSelection, - onExecutionStarting + onExecutionStarting, + resolved.requestSettings ); } const globalRouter: GlobalCommandRequestRouter = new GlobalCommandRequestRouter(workspaceSession); diff --git a/libraries/rush-daemon/src/PhasedRequestRouter.ts b/libraries/rush-daemon/src/PhasedRequestRouter.ts index 0903eaf136..092efe51ce 100644 --- a/libraries/rush-daemon/src/PhasedRequestRouter.ts +++ b/libraries/rush-daemon/src/PhasedRequestRouter.ts @@ -4,6 +4,7 @@ import type { IOperationExecutionResult, IOperationGraph, + IPhasedCommandEngineRequestSettings, Operation, _IOperationGraphEventSink } from '@microsoft/rush-lib'; @@ -65,6 +66,9 @@ interface IPreparedPhasedRequest { readonly exclusivityClass: RequestExclusivityClass; readonly interactiveSession: IInteractiveRequestSession | undefined; readonly request: IDaemonPhasedRequest; + /** Only requests with the same settings share one graph iteration. */ + readonly requestSettings: IPhasedCommandEngineRequestSettings | undefined; + readonly requestSettingsKey: string; readonly selection: IResolvedSelection; readonly warningsAllowedByEnvironment: boolean; } @@ -111,7 +115,8 @@ export class PhasedRequestRouter { request: IDaemonPhasedRequest, client: IPhasedRequestClient, exactSelection: boolean = false, - onExecutionStarting?: () => void + onExecutionStarting?: () => void, + requestSettings?: IPhasedCommandEngineRequestSettings ): Promise { validateRequestIdentity(request); const interactiveSession: IInteractiveRequestSession | undefined = validateInteractiveSession( @@ -189,6 +194,8 @@ export class PhasedRequestRouter { exclusivityClass, interactiveSession, request, + requestSettings, + requestSettingsKey: JSON.stringify(requestSettings ?? null), selection, warningsAllowedByEnvironment, onExecutionStarting @@ -336,14 +343,19 @@ class PhasedRequestBatchCoordinator { } return ( this.#acceptingCurrentBatch && - this.#currentBatch?.[0]?.exclusivityClass === RequestExclusivityClass.SharedBuild + this.#currentBatch?.[0]?.exclusivityClass === RequestExclusivityClass.SharedBuild && + this.#currentBatch[0].requestSettingsKey === request.requestSettingsKey ); } #takeCompatiblePending(batch: IBatchEntry[]): void { + const { requestSettingsKey } = batch[0]; for (let index: number = 0; index < this.#pending.length; ) { const entry: IBatchEntry = this.#pending[index]; - if (entry.exclusivityClass === RequestExclusivityClass.SharedBuild) { + if ( + entry.exclusivityClass === RequestExclusivityClass.SharedBuild && + entry.requestSettingsKey === requestSettingsKey + ) { this.#pending.splice(index, 1); entry.executionStarted = true; batch.push(entry); @@ -393,6 +405,7 @@ class PhasedRequestBatchCoordinator { return; } + applyRequestSettings(this.#graph, participants[0].requestSettings); applySelections( this.#graph, participants.map((entry: IBatchEntry) => entry.selection) @@ -812,6 +825,17 @@ function collectSelectionClosure( return Array.from(activeOperations); } +/** Presentation/scheduling settings are request-scoped, so they are applied per iteration, not per graph. */ +function applyRequestSettings( + graph: IOperationGraph, + settings: IPhasedCommandEngineRequestSettings | undefined +): void { + if (settings) { + graph.quietMode = settings.quietMode; + graph.parallelism = settings.parallelism; + } +} + function applySelections(graph: IOperationGraph, selections: ReadonlyArray): void { const enabledClosureBySelection: ReadonlyArray> = selections.map( (selection: IResolvedSelection) => diff --git a/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts b/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts index 7a1793510a..3a2bc6ecf5 100644 --- a/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts +++ b/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts @@ -37,7 +37,9 @@ import type { IWorkspaceResolverLifecycle } from './WorkspaceResolverLifecycle'; * Binds the standalone host to a real native build/rebuild graph on its first request. * * @remarks - * A host is pinned to its first command and non-selection parameters. Incompatible parameters, + * A host is pinned to its first command and graph-affecting, non-selection parameters. Presentation and + * scheduling parameters (`--verbose`, `--parallelism`, `--timeline`) are applied per request instead. + * Incompatible parameters, * environments, or graph inputs are rejected before scheduling; no request is retried automatically. * The initial supported surface excludes external plugins, .env initialization, install/watch, * event-hook scripts, and rushx/global commands. Use the unchanged native CLI for those surfaces. @@ -117,6 +119,7 @@ export class ProductionDaemonRequestResolver implements IDaemonRequestResolver { return { kind: 'phased', exactSelection: true, + requestSettings: command.requestSettings, request: { admission: envelope.admission, commandName: envelope.commandName, diff --git a/libraries/rush-daemon/src/test/WorkspaceRequestScopedParameters.test.ts b/libraries/rush-daemon/src/test/WorkspaceRequestScopedParameters.test.ts new file mode 100644 index 0000000000..dddefa02d3 --- /dev/null +++ b/libraries/rush-daemon/src/test/WorkspaceRequestScopedParameters.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { WorkspaceInputChangeTier, type IOperationGraph } from '@microsoft/rush-lib'; + +import { DaemonGraphTestFixture } from './DaemonGraphTestFixture'; +import { setDaemonPolicy } from './WarmGenerationTestUtilities'; + +jest.setTimeout(30_000); + +it('applies --verbose and --parallelism per request without reloading the warm graph', async () => { + const fixture = await DaemonGraphTestFixture.createAsync((created) => setDaemonPolicy(created, {})); + try { + const buildAsync = async (...extra: string[]): Promise => { + const result = await fixture.runAsync(['build', '--to', 'b', ...extra]); + expect(result.terminal).toMatchObject({ payload: { exitCode: 0 } }); + }; + await buildAsync(); + expect(fixture.runs()).toEqual(['a', 'b']); + const generation: number = fixture.host.workspaceGeneration; + const graph: IOperationGraph | undefined = fixture.session.operationGraph; + const defaultParallelism: number = graph!.parallelism; + const expectWarm = (): void => { + expect(fixture.host.workspaceGeneration).toBe(generation); + expect(fixture.session.operationGraph).toBe(graph); + expect(fixture.host.workspaceStatus.lastReloadTier).toBe(WorkspaceInputChangeTier.Reuse); + expect(fixture.runs()).toEqual(['a', 'b']); + }; + + await buildAsync(); + expectWarm(); + await buildAsync('--verbose'); + expectWarm(); + expect(graph!.quietMode).toBe(false); + await buildAsync(); + expectWarm(); + expect(graph!.quietMode).toBe(true); + await buildAsync('-p', '1', '--timeline'); + expectWarm(); + expect(graph!.parallelism).toBe(1); + await buildAsync(); + expectWarm(); + expect(graph!.parallelism).toBe(defaultParallelism); + + fixture.write('a/input.txt', 'two'); + await buildAsync('--verbose', '-p', '1'); + expect(fixture.host.workspaceGeneration).toBe(generation); + expect(fixture.runs()).toEqual(['a', 'b', 'a', 'b']); + } finally { + await fixture[Symbol.asyncDispose](); + } +}); diff --git a/libraries/rush-lib/src/api/PhasedCommandEngine.ts b/libraries/rush-lib/src/api/PhasedCommandEngine.ts index 0fefc3fddf..e5246fbfa1 100644 --- a/libraries/rush-lib/src/api/PhasedCommandEngine.ts +++ b/libraries/rush-lib/src/api/PhasedCommandEngine.ts @@ -12,6 +12,7 @@ import { PhasedScriptAction } from '../cli/scriptActions/PhasedScriptAction'; import type { GetInputsSnapshotAsyncFn, IInputsSnapshot } from '../logic/incremental/InputsSnapshot'; import type { IOperationGraph } from '../logic/operations/IOperationGraph'; import type { Operation, OperationEnabledState } from '../logic/operations/Operation'; +import type { Parallelism } from '../logic/operations/ParseParallelism'; import { PhasedCommandEngineExecution } from '../logic/operations/PhasedCommandEngineExecution'; import type { RushSession } from '../pluginFramework/RushSession'; import type { RushConfiguration } from './RushConfiguration'; @@ -44,6 +45,17 @@ export interface IParsePhasedCommandOptions { readonly terminalProvider: ITerminalProvider; } +/** + * Presentation and scheduling settings of one parsed command. They do not affect the operation graph or any + * operation hash, so they are not part of `PhasedCommandEngine.parameterIdentity`; hosts apply them to the + * shared graph (`IOperationGraph.quietMode` / `IOperationGraph.parallelism`) before each iteration. + * @alpha + */ +export interface IPhasedCommandEngineRequestSettings { + readonly quietMode: boolean; + readonly parallelism: Parallelism; +} + /** * A parsed native build/rebuild command. Parsing never runs scripts or changes cwd/process.env. * @@ -164,4 +176,9 @@ export class PhasedCommandEngine { ): Promise> { return await this._action.selectEngineOperationsAsync(graph); } + + /** Presentation and scheduling settings requested by this command; not part of `parameterIdentity`. */ + public get requestSettings(): IPhasedCommandEngineRequestSettings { + return this._action.getEngineRequestSettings(); + } } diff --git a/libraries/rush-lib/src/api/test/PhasedCommandEngineParameterIdentity.test.ts b/libraries/rush-lib/src/api/test/PhasedCommandEngineParameterIdentity.test.ts new file mode 100644 index 0000000000..6aebbe7d35 --- /dev/null +++ b/libraries/rush-lib/src/api/test/PhasedCommandEngineParameterIdentity.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { NoOpTerminalProvider } from '@rushstack/terminal'; + +import { PhasedCommandEngine } from '../PhasedCommandEngine'; +import { RushConfiguration } from '../RushConfiguration'; +import { Rush } from '../Rush'; +import { parseParallelism } from '../../logic/operations/ParseParallelism'; + +describe(`${PhasedCommandEngine.name} parameter identity`, () => { + let folder: string; + let rushConfiguration: RushConfiguration; + + function write(name: string, value: unknown): void { + const filename: string = path.join(folder, name); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, JSON.stringify(value)); + } + + async function parseAsync(...argv: string[]): Promise { + return await PhasedCommandEngine.parseAsync({ + argv, + cwd: folder, + rushConfiguration, + terminalProvider: new NoOpTerminalProvider() + }); + } + + beforeAll(() => { + folder = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'rush-engine-identity-'))); + write('rush.json', { + rushVersion: Rush.version, + npmVersion: '10.0.0', + projectFolderMinDepth: 1, + projects: [{ packageName: 'a', projectFolder: 'a' }] + }); + write('a/package.json', { name: 'a', version: '1.0.0', scripts: { '_phase:compile': 'node -v' } }); + write('common/config/rush/command-line.json', { + phases: [{ name: '_phase:compile', dependencies: { upstream: ['_phase:compile'] } }], + commands: [ + { + commandKind: 'phased', + name: 'build', + summary: 'Build', + phases: ['_phase:compile'], + incremental: true, + enableParallelism: true + } + ], + parameters: [ + { + parameterKind: 'flag', + longName: '--production', + description: 'A graph-affecting custom parameter', + associatedCommands: ['build'], + associatedPhases: ['_phase:compile'] + } + ] + }); + rushConfiguration = RushConfiguration.loadFromConfigurationFile(path.join(folder, 'rush.json')); + }); + + afterAll(() => { + fs.rmSync(folder, { recursive: true, force: true }); + }); + + it('excludes presentation and scheduling parameters from the identity', async () => { + const baseline: string = (await parseAsync('build')).parameterIdentity; + for (const argv of [ + ['build', '--verbose'], + ['build', '-v'], + ['build', '--parallelism', '2'], + ['build', '-p', 'max'], + ['build', '--timeline'], + ['build', '--to', 'a'], + ['build', '--verbose', '-p', '1', '--timeline', '--only', 'a'] + ]) { + expect((await parseAsync(...argv)).parameterIdentity).toBe(baseline); + } + }); + + it('includes graph-affecting parameters and the command name in the identity', async () => { + const baseline: string = (await parseAsync('build')).parameterIdentity; + expect((await parseAsync('build', '--production')).parameterIdentity).not.toBe(baseline); + expect((await parseAsync('build', '--production', '--verbose')).parameterIdentity).toBe( + (await parseAsync('build', '--production')).parameterIdentity + ); + expect((await parseAsync('rebuild')).parameterIdentity).not.toBe(baseline); + }); + + it('reports the excluded settings per request', async () => { + expect((await parseAsync('build')).requestSettings).toEqual({ + quietMode: true, + parallelism: parseParallelism(undefined) + }); + expect((await parseAsync('build', '--verbose', '-p', '2')).requestSettings).toEqual({ + quietMode: false, + parallelism: 2 + }); + expect((await parseAsync('build', '-p', '50%')).requestSettings).toEqual({ + quietMode: true, + parallelism: { scalar: 0.5 } + }); + }); +}); diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 2818bca1f7..89a0dbd3f7 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -32,7 +32,10 @@ import type { IOperationGraph, IOperationGraphIterationOptions } from '../../logic/operations/IOperationGraph'; -import type { IPhasedCommandEngine } from '../../api/PhasedCommandEngine'; +import type { + IPhasedCommandEngine, + IPhasedCommandEngineRequestSettings +} from '../../api/PhasedCommandEngine'; import { PhasedCommandEngineConfigurationChangedError } from '../../api/PhasedCommandEngineConfigurationChangedError'; import { getDaemonIpcImplementationIdentityAsync } from '../../logic/operations/DaemonIpcConfiguration'; import { SetupChecks } from '../../logic/SetupChecks'; @@ -82,6 +85,17 @@ import { _isRushSessionOperationStreamEnabled } from '../../pluginFramework/Rush const PERF_PREFIX: 'rush:phasedScriptAction' = 'rush:phasedScriptAction'; +/** + * Parameters that change neither the operation graph nor any operation hash. A long-lived engine applies + * them per request (see `getEngineRequestSettings`), so they are excluded from the engine parameter identity. + * `--timeline` only adds a presentation plugin whose output is discarded by engine hosts. + */ +const ENGINE_REQUEST_SCOPED_PARAMETER_NAMES: ReadonlySet = new Set([ + '--verbose', + '--parallelism', + '--timeline' +]); + /** * The set of overall execution statuses that mean the command did what was asked of it and should * exit with code 0. @@ -370,10 +384,23 @@ export class PhasedScriptAction extends BaseScriptAction i return JSON.stringify([ this.actionName, this.parser.getParameterStringMap(), - Object.entries(this.getParameterStringMap()).filter(([name]) => !selectionNames.has(name)) + Object.entries(this.getParameterStringMap()).filter( + ([name]) => !selectionNames.has(name) && !ENGINE_REQUEST_SCOPED_PARAMETER_NAMES.has(name) + ) ]); } + /** + * Output verbosity and scheduling settings for one engine request. These are excluded from + * `getEngineParameterIdentity` and must be applied to the shared graph before each iteration. + */ + public getEngineRequestSettings(): IPhasedCommandEngineRequestSettings { + return { + quietMode: !this.#verboseParameter.value, + parallelism: this.#enableParallelism ? parseParallelism(this.#parallelismParameter?.value) : 1 + }; + } + public async selectEngineOperationsAsync( graph: IOperationGraph ): Promise> { diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index 352a7ae512..3604f00884 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -177,6 +177,7 @@ export { OperationStatus } from './logic/operations/OperationStatus'; export { PhasedCommandEngine, type IPhasedCommandEngine, + type IPhasedCommandEngineRequestSettings, type IParsePhasedCommandOptions } from './api/PhasedCommandEngine'; export { PhasedCommandEngineConfigurationChangedError } from './api/PhasedCommandEngineConfigurationChangedError'; From 4739f44304064b704eb109901d111177af2a855a Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 18:52:42 -0700 Subject: [PATCH 2/3] [rush-lib] Update API reports for per-request engine settings Fixes #6048 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- common/reviews/api/rush-daemon.api.md | 4 +++- common/reviews/api/rush-lib.api.md | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/common/reviews/api/rush-daemon.api.md b/common/reviews/api/rush-daemon.api.md index bbad30a4b9..754fe2fdf0 100644 --- a/common/reviews/api/rush-daemon.api.md +++ b/common/reviews/api/rush-daemon.api.md @@ -27,6 +27,7 @@ import type { IDaemonWarmSetStatus } from '@rushstack/rush-daemon-protocol'; import type { IDaemonWorkspaceStatus } from '@rushstack/rush-daemon-protocol'; import type { IInputsSnapshot } from '@microsoft/rush-lib'; import { IOperationGraph } from '@microsoft/rush-lib'; +import type { IPhasedCommandEngineRequestSettings } from '@microsoft/rush-lib'; import type { ITerminal } from '@rushstack/terminal'; import { LockFile } from '@rushstack/node-core-library'; import { Operation } from '@microsoft/rush-lib'; @@ -399,6 +400,7 @@ export interface IResolvedDaemonPhasedRequest { readonly kind: 'phased'; // (undocumented) readonly request: IDaemonPhasedRequest; + readonly requestSettings?: IPhasedCommandEngineRequestSettings; } // @beta @@ -666,7 +668,7 @@ export type MapWorkspaceInvalidationsToOperationsAsync = (options: IMapWorkspace // @beta export class PhasedRequestRouter { constructor(workspaceSession: IWorkspaceSession); - executeAsync(request: IDaemonPhasedRequest, client: IPhasedRequestClient, exactSelection?: boolean, onExecutionStarting?: () => void): Promise; + executeAsync(request: IDaemonPhasedRequest, client: IPhasedRequestClient, exactSelection?: boolean, onExecutionStarting?: () => void, requestSettings?: IPhasedCommandEngineRequestSettings): Promise; } // @beta diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 512a2dcf7d..e68e1bb3ae 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -950,6 +950,14 @@ export interface IPhasedCommandEngine extends AsyncDisposable { readonly rushSession: RushSession; } +// @alpha +export interface IPhasedCommandEngineRequestSettings { + // (undocumented) + readonly parallelism: Parallelism; + // (undocumented) + readonly quietMode: boolean; +} + // @alpha export interface IPhasedCommandPlugin { apply(hooks: PhasedCommandHooks): void; @@ -1509,6 +1517,7 @@ export class PhasedCommandEngine { readonly parameterIdentity: string; // (undocumented) static parseAsync(options: IParsePhasedCommandOptions): Promise; + get requestSettings(): IPhasedCommandEngineRequestSettings; selectOperationsAsync(graph: IOperationGraph): Promise>; } From 36824ef9255a6e1e83cd10ffba9a4f2d87fea383 Mon Sep 17 00:00:00 2001 From: selarkin Date: Thu, 24 Sep 2026 11:05:31 -0700 Subject: [PATCH 3/3] [rush-daemon] Test that differing request settings are not batched together Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/test/PhasedRequestBatching.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/libraries/rush-daemon/src/test/PhasedRequestBatching.test.ts b/libraries/rush-daemon/src/test/PhasedRequestBatching.test.ts index 9ed2adef1c..83914b6741 100644 --- a/libraries/rush-daemon/src/test/PhasedRequestBatching.test.ts +++ b/libraries/rush-daemon/src/test/PhasedRequestBatching.test.ts @@ -10,6 +10,7 @@ import type { } from '@rushstack/rush-daemon-protocol'; import { RUSHD_OPERATION_HEADER } from '@rushstack/rush-daemon-protocol'; import { OperationStatus } from '@microsoft/rush-lib'; +import type { IPhasedCommandEngineRequestSettings } from '@microsoft/rush-lib'; import { PhasedRequestRouter } from '../PhasedRequestRouter'; import { @@ -100,6 +101,45 @@ function eventOperationId(event: IDaemonEventEnvelope): string | undefined { } describe('shared phased request batching', () => { + it('schedules separate iterations for overlapping requests with different request settings', async () => { + const fixture: ITestRoutingFixture = createFixture(); + const graph: ITestRoutingFixture['graph'] = fixture.graph; + const scheduledSettings: IPhasedCommandEngineRequestSettings[] = []; + const originalScheduleAsync: typeof graph.scheduleIterationAsync = + graph.scheduleIterationAsync.bind(graph); + const scheduleSpy: jest.SpyInstance = jest + .spyOn(graph, 'scheduleIterationAsync') + .mockImplementation((...args: Parameters) => { + scheduledSettings.push({ parallelism: graph.parallelism, quietMode: graph.quietMode }); + return originalScheduleAsync(...args); + }); + const router: PhasedRequestRouter = new PhasedRequestRouter(fixture.session); + const defaultSettings: IPhasedCommandEngineRequestSettings = { parallelism: 4, quietMode: true }; + const verboseSerialSettings: IPhasedCommandEngineRequestSettings = { parallelism: 1, quietMode: false }; + + const [first, second] = await Promise.all([ + router.executeAsync( + createRequest('default', OPERATION_A), + new TestPhasedRequestClient('one'), + false, + undefined, + defaultSettings + ), + router.executeAsync( + createRequest('verbose-serial', OPERATION_B), + new TestPhasedRequestClient('two'), + false, + undefined, + verboseSerialSettings + ) + ]); + + expect(scheduleSpy).toHaveBeenCalledTimes(2); + expect(scheduledSettings).toEqual([defaultSettings, verboseSerialSettings]); + expect(first).toMatchObject({ exitCode: 0, outcome: 'success' }); + expect(second).toMatchObject({ exitCode: 0, outcome: 'success' }); + }); + it('merges overlapping selections into one real graph iteration and executes shared operations once', async () => { const fixture: ITestRoutingFixture = createFixture(); const scheduleSpy: jest.SpyInstance = jest.spyOn(fixture.graph, 'scheduleIterationAsync');