From c637a7b29d0ee1401b0aa5cc9f0b1b5af24be1e7 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 19:34:01 -0700 Subject: [PATCH 1/3] [rush-cli-client] Add agent output mode and honor useRushReporter on the daemon path Fixes #6076 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/rush-cli-client/README.md | 33 ++- .../src/AgentProgressRenderer.ts | 275 ++++++++++++++++++ apps/rush-cli-client/src/launchClient.ts | 43 ++- apps/rush-cli-client/src/outputSelection.ts | 76 +++++ apps/rush-cli-client/src/routing.ts | 36 ++- apps/rush-cli-client/src/start.ts | 29 +- .../src/test/AgentProgressRenderer.test.ts | 153 ++++++++++ .../src/test/outputSelection.test.ts | 24 ++ apps/rush-cli-client/src/test/routing.test.ts | 20 +- .../agent-reporter_2026-09-24-02-35.json | 11 + 10 files changed, 686 insertions(+), 14 deletions(-) create mode 100644 apps/rush-cli-client/src/AgentProgressRenderer.ts create mode 100644 apps/rush-cli-client/src/outputSelection.ts create mode 100644 apps/rush-cli-client/src/test/AgentProgressRenderer.test.ts create mode 100644 apps/rush-cli-client/src/test/outputSelection.test.ts create mode 100644 common/changes/@rushstack/rush-cli-client/agent-reporter_2026-09-24-02-35.json diff --git a/apps/rush-cli-client/README.md b/apps/rush-cli-client/README.md index 1d4cf98eb5..288df22b8f 100644 --- a/apps/rush-cli-client/README.md +++ b/apps/rush-cli-client/README.md @@ -52,8 +52,37 @@ retains native command behavior. Waiting positions are shown on interactive stde and admission failures report their typed reason and a nonzero exit code. Explicit reporter/output/log-level controls retain the native frontend reporter path. -The current daemon client renders the legacy operation stream; it does not silently -reinterpret requests for JSON, AI, file, or other reporter formats. +The daemon client does not silently reinterpret requests for JSON, file, or other +reporter formats. The one exception is the AI reporter: `--reporter=ai` (or +`--reporter ai`, or `RUSH_REPORTER=ai`) stays on the daemon path, is removed from +the forwarded argv, and selects the client's agent output (see below). Combined with +other controls (`--output`, `--log-level`, `RUSH_LOG_LEVEL`) it stays native. + +A repository that opts into the native reporter with `"useRushReporter": true` in +`common/config/rush/experiments.json` also stays on the native (in-process) path so +that its reporter output is honored rather than silently replaced by the daemon +stream. Only an explicit AI reporter request keeps such a repository on the daemon +path. Native reporter rendering over the daemon protocol is a follow-up. + +### Output modes + +The `rush-client` daemon path has two output modes (`rushx-client` always uses `legacy`). Selection precedence: + +1. `RUSHD_OUTPUT=agent` or `RUSHD_OUTPUT=legacy`. +2. An explicit AI reporter request (`--reporter=ai` or `RUSH_REPORTER=ai`) selects `agent`. +3. An active `COPILOT_CLI` agent marker selects `agent`, matching `detectAgent()` in + `@rushstack/reporter` (a value is inactive when empty, `0`, `false`, `no` or `off`). + Other agents can opt in with `RUSHD_OUTPUT=agent`. +4. Otherwise `legacy`: the unchanged collated operation stream. + +Agent mode writes a first status line before `@microsoft/rush-lib` is loaded, then +at most three live rows on a TTY (append-only lines throttled to one per 2 seconds on +a pipe), the queue position when waiting for admission, and always one final summary +line (`rush build: SUCCESS 12/12 operations (...) in 3.1s`, or +`up to date (no operations needed)`). On failure, it lists failed operations and a +bounded tail (10 lines) of their stderr, or of their stdout when they wrote no stderr. +Operation logs are otherwise not printed; use `RUSHD_OUTPUT=legacy` for full logs. When a request falls back to in-process Rush, agent mode stops and +native output follows. Positively identified built-in `install` and `update` follow the same opt-in routing precedence as workspace builds and require protocol **0.10** diff --git a/apps/rush-cli-client/src/AgentProgressRenderer.ts b/apps/rush-cli-client/src/AgentProgressRenderer.ts new file mode 100644 index 0000000000..03b83a450f --- /dev/null +++ b/apps/rush-cli-client/src/AgentProgressRenderer.ts @@ -0,0 +1,275 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Keep this module free of heavy imports: start.ts loads it before @microsoft/rush-lib +// so that the first line can be written within a few milliseconds. + +import type { IDaemonEventEnvelope } from '@rushstack/rush-daemon-protocol'; + +const SPINNER_FRAMES: readonly string[] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'SUCCESS', + 'SUCCESS WITH WARNINGS', + 'SKIPPED', + 'FROM CACHE', + 'FAILURE', + 'BLOCKED', + 'NO OP' +]); +const MAX_ERROR_LINES: number = 10; +const PIPE_MIN_INTERVAL_MS: number = 2000; +const PIPE_HEARTBEAT_MS: number = 10000; +const TTY_INTERVAL_MS: number = 100; + +export interface IAgentProgressRendererOptions { + readonly commandName: string; + readonly isTTY: boolean; + readonly columns: number; + readonly write: (text: string) => void; + readonly now?: () => number; + readonly startTimeMs?: number; +} + +interface IAgentFinalResult { + readonly exitCode: number; + readonly errorMessage?: string; +} + +/** + * Compact progress for agents on the daemon path: an immediate first line, at most + * three live rows (TTY) or throttled append-only lines (pipes), and a guaranteed + * bounded final summary line, even when no operation ran. + */ +export class AgentProgressRenderer { + readonly #options: IAgentProgressRendererOptions; + readonly #now: () => number; + readonly #startTimeMs: number; + readonly #registered: Set = new Set(); + readonly #statuses: Map = new Map(); + readonly #running: Set = new Set(); + readonly #counts: Map = new Map(); + readonly #failed: string[] = []; + readonly #errorLines: string[] = []; + readonly #stderrOperations: Set = new Set(); + readonly #stdoutTails: Map = new Map(); + #total: number = 0; + #done: number = 0; + #lastActivity: string = ''; + #phase: string = 'connecting to rushd (auto-starts if needed)'; + #painted: number = 0; + #frame: number = 0; + #lastLineKey: string = ''; + #lastLineAtMs: number = -Infinity; + #timer: ReturnType | undefined; + + public constructor(options: IAgentProgressRendererOptions) { + this.#options = options; + this.#now = options.now ?? Date.now; + this.#startTimeMs = options.startTimeMs ?? this.#now(); + } + + /** Writes the first line and starts the spinner / heartbeat. */ + public start(): void { + this.#render(true); + this.#timer = setInterval( + () => this.#render(false), + this.#options.isTTY ? TTY_INTERVAL_MS : PIPE_MIN_INTERVAL_MS + ); + this.#timer.unref?.(); + } + + public setPhase(phase: string): void { + this.#phase = phase; + this.#render(true); + } + + public onQueuePosition(position: number): void { + this.setPhase(`queued behind another request (position ${position})`); + } + + public onEvent(event: IDaemonEventEnvelope): void { + const payload: Record = (event.payload ?? {}) as Record; + switch (event.type) { + case 'operationRegistered': { + if (!payload.silent && typeof payload.operationId === 'string') { + this.#registered.add(payload.operationId); + } + break; + } + case 'operationStatusChanged': { + const operationId: unknown = payload.operationId; + const status: unknown = payload.status; + if (typeof operationId !== 'string' || typeof status !== 'string') { + break; + } + this.#phase = 'running'; + const previous: string | undefined = this.#statuses.get(operationId); + this.#statuses.set(operationId, status); + if (status === 'EXECUTING') { + this.#running.add(operationId); + } + if (TERMINAL_STATUSES.has(status) && (previous === undefined || !TERMINAL_STATUSES.has(previous))) { + this.#running.delete(operationId); + this.#done++; + this.#counts.set(status, (this.#counts.get(status) ?? 0) + 1); + if (status === 'FAILURE') { + this.#failed.push(operationId); + } else { + this.#stdoutTails.delete(operationId); + } + } + break; + } + case 'extension': { + const data: { totalOperations?: unknown } | undefined = payload.data as + | { totalOperations?: unknown } + | undefined; + if (data && typeof data.totalOperations === 'number') { + this.#total = Math.max(this.#total, data.totalOperations); + } + break; + } + case 'activityChanged': { + if (typeof payload.text === 'string' && payload.text.trim()) { + this.#lastActivity = payload.text.trim().split('\n')[0]; + if (this.#phase !== 'running') { + this.#phase = 'running'; + } + } + break; + } + } + this.#render(false); + } + + /** + * Keeps a bounded stderr tail, plus a small per-operation stdout tail that is only + * printed for failed operations whose diagnostics went to stdout (tsc, eslint, jest). + */ + public onLog(bytes: Uint8Array, operationId: string, stream: 'stdout' | 'stderr'): void { + for (const line of Buffer.from(bytes).toString('utf8').split('\n')) { + if (!line.trim()) { + continue; + } + if (stream === 'stderr') { + this.#stderrOperations.add(operationId); + if (this.#errorLines.length < MAX_ERROR_LINES) { + this.#errorLines.push(`${operationId}: ${line.trim()}`); + } + } else { + const status: string | undefined = this.#statuses.get(operationId); + if (status !== undefined && status !== 'EXECUTING' && status !== 'FAILURE') { + continue; + } + let tail: string[] | undefined = this.#stdoutTails.get(operationId); + if (!tail) { + tail = []; + this.#stdoutTails.set(operationId, tail); + } + tail.push(line.trim()); + if (tail.length > MAX_ERROR_LINES) { + tail.shift(); + } + } + } + } + + /** Stops rendering without a summary (e.g. the request is handed to in-process Rush). */ + public dispose(finalNote?: string): void { + if (this.#timer) { + clearInterval(this.#timer); + this.#timer = undefined; + } + this.#clear(); + if (finalNote) { + this.#options.write(`rush ${this.#options.commandName}: ${finalNote}\n`); + } + } + + /** Stops the live region and writes the final summary line. */ + public finish(result: IAgentFinalResult | undefined): void { + if (this.#timer) { + clearInterval(this.#timer); + this.#timer = undefined; + } + this.#clear(); + const succeeded: boolean = result !== undefined && result.exitCode === 0; + const total: number = this.#getTotal(); + const parts: string[] = [...this.#counts].map(([status, count]) => `${count} ${status.toLowerCase()}`); + const scope: string = + total === 0 && succeeded + ? 'up to date (no operations needed)' + : `${this.#done}/${total} operations${parts.length ? ` (${parts.join(', ')})` : ''}`; + let line: string = `rush ${this.#options.commandName}: ${succeeded ? 'SUCCESS' : 'FAILURE'} ${scope} in ${this.#elapsed()}`; + if (this.#failed.length) { + line += ` · failed: ${this.#failed.join(', ')}`; + } + if (result?.errorMessage) { + line += ` · ${result.errorMessage}`; + } + this.#options.write(`${line}\n`); + if (!succeeded) { + const lines: string[] = [...this.#errorLines]; + for (const operationId of this.#failed) { + if (!this.#stderrOperations.has(operationId)) { + for (const stdoutLine of this.#stdoutTails.get(operationId) ?? []) { + lines.push(`${operationId}: ${stdoutLine}`); + } + } + } + for (const errorLine of lines.slice(0, MAX_ERROR_LINES)) { + this.#options.write(` ${errorLine}\n`); + } + } + } + + #getTotal(): number { + return Math.max(this.#total, this.#registered.size, this.#done); + } + + #elapsed(): string { + return `${((this.#now() - this.#startTimeMs) / 1000).toFixed(1)}s`; + } + + #rows(): [string, string, string] { + const total: number = this.#getTotal(); + const running: string[] = [...this.#running]; + const shown: string = + running.slice(0, 3).join(', ') + (running.length > 3 ? ` +${running.length - 3} more` : ''); + const counter: string = total ? ` ${this.#done}/${total}` : ''; + return [ + `rush ${this.#options.commandName}${counter} · ${this.#elapsed()} · ${this.#phase}`, + running.length ? `running: ${shown}` : '', + this.#lastActivity + ]; + } + + #render(force: boolean): void { + const rows: [string, string, string] = this.#rows(); + if (this.#options.isTTY) { + const width: number = Math.max(20, this.#options.columns || 80) - 1; + const clip = (row: string): string => (row.length > width ? `${row.slice(0, width - 1)}…` : row); + const frame: string = SPINNER_FRAMES[this.#frame++ % SPINNER_FRAMES.length]; + const text: string = [`${frame} ${rows[0]}`, rows[1], rows[2]].map(clip).join('\n'); + this.#options.write(`${this.#painted ? `\x1b[${this.#painted}A\x1b[0J` : '\x1b[?25l'}${text}\n`); + this.#painted = 3; + return; + } + const key: string = `${this.#phase}|${this.#done}`; + const nowMs: number = this.#now(); + const sinceLast: number = nowMs - this.#lastLineAtMs; + if (!force && (sinceLast < PIPE_MIN_INTERVAL_MS || (key === this.#lastLineKey && sinceLast < PIPE_HEARTBEAT_MS))) { + return; + } + this.#lastLineKey = key; + this.#lastLineAtMs = nowMs; + this.#options.write(`${rows[0]}${rows[1] ? ` · ${rows[1]}` : ''}\n`); + } + + #clear(): void { + if (this.#options.isTTY && this.#painted) { + this.#options.write(`\x1b[${this.#painted}A\x1b[0J\x1b[?25h`); + this.#painted = 0; + } + } +} diff --git a/apps/rush-cli-client/src/launchClient.ts b/apps/rush-cli-client/src/launchClient.ts index b739b9fadf..a2053a26cb 100644 --- a/apps/rush-cli-client/src/launchClient.ts +++ b/apps/rush-cli-client/src/launchClient.ts @@ -27,6 +27,7 @@ import { DaemonLauncherUnavailableError } from '@rushstack/rush-daemon/lib/Versi import { executeDaemonCommandAsync } from './daemonCommands'; import { ClientOperationRenderer } from './ClientOperationRenderer'; +import type { AgentProgressRenderer } from './AgentProgressRenderer'; import { getDaemonConnectionOptionsAsync } from './daemonConnectionOptions'; import { selectClientRoute, type IClientRoute } from './routing'; import { writeStreamAsync } from './writeStreamAsync'; @@ -36,7 +37,21 @@ interface IWorkspaceJson { readonly daemon?: IDaemonConfigurationJson; } -export async function launchClientAsync(rushx: boolean): Promise { +/** Reads the repository's experiments.json `useRushReporter` opt-in. */ +function readUseRushReporter(rushJsonPath: string): boolean { + const experimentsPath: string = path.join(path.dirname(rushJsonPath), 'common', 'config', 'rush', 'experiments.json'); + try { + return (JsonFile.load(experimentsPath) as { useRushReporter?: unknown }).useRushReporter === true; + } catch { + // A missing or unreadable file means the opt-in is absent; in-process Rush reports invalid files. + return false; + } +} + +export async function launchClientAsync( + rushx: boolean, + agentRenderer?: AgentProgressRenderer +): Promise { const cwd: string = process.cwd(); const environment: Readonly = Object.freeze({ ...process.env }); const rushJsonPath: string | undefined = RushConfiguration.tryFindRushJsonLocation({ startingFolder: cwd }); @@ -50,10 +65,12 @@ export async function launchClientAsync(rushx: boolean): Promise { environment, enabled: config.enabled, rushx, - hasTerminal: !!(process.stdin.isTTY || process.stdout.isTTY || process.stderr.isTTY) + hasTerminal: !!(process.stdin.isTTY || process.stdout.isTTY || process.stderr.isTTY), + useRushReporter: !rushx && !!rushJsonPath && readUseRushReporter(rushJsonPath) }); const selectedVersion: string = environment.RUSH_PREVIEW_VERSION ?? workspace?.rushVersion ?? Rush.version; if (!rushx && route.commandName === 'daemon') { + agentRenderer?.dispose(); if ((route.argv[1] === 'start' || route.argv[1] === 'restart') && process.argv.includes('--no-daemon')) { throw new Error(`--no-daemon cannot be combined with daemon ${route.argv[1]}.`); } @@ -70,12 +87,17 @@ export async function launchClientAsync(rushx: boolean): Promise { return; } if (!route.daemon || !rushJsonPath || route.commandName === undefined) { + agentRenderer?.dispose('not routed to rushd; running in-process Rush'); launchInProcess(route.argv, rushx, selectedVersion); return; } const terminal: ConsoleTerminalProvider = new ConsoleTerminalProvider(); const verbosity: DaemonVerbosity = - route.argv.includes('--verbose') || route.argv.includes('-v') ? 'verbose' : 'quiet'; + route.argv.includes('--verbose') || route.argv.includes('-v') + ? 'verbose' + : agentRenderer + ? 'normal' + : 'quiet'; const request: IDaemonRequestEnvelope = captureDaemonRequest({ argv: route.argv, commandName: route.commandName, @@ -114,6 +136,7 @@ export async function launchClientAsync(rushx: boolean): Promise { } catch (error) { if (!(error instanceof DaemonClientError) && !(error instanceof DaemonLauncherUnavailableError)) throw error; + agentRenderer?.dispose(); process.stderr.write(`rush-client: ${error.message} Using in-process Rush.\n`); launchInProcess(route.argv, rushx, selectedVersion); return; @@ -147,19 +170,25 @@ export async function launchClientAsync(rushx: boolean): Promise { try { if (rushx) MinimalRushConfiguration.loadFromDefaultLocation((line) => discoveryLines.push(line)); await renderer.initializeAsync(); + agentRenderer?.setPhase('request submitted; preparing the workspace graph'); outcome = await executeWithDaemonRestartAsync(client, connection, { request, abortSignal: abort.signal, onStdoutAsync: async (bytes, operationId) => { + if (agentRenderer) return agentRenderer.onLog(bytes, operationId, 'stdout'); await writeDiscoveryAsync(); await renderer.writeLogAsync(bytes, operationId, 'stdout'); }, onStderrAsync: async (bytes, operationId) => { + if (agentRenderer) return agentRenderer.onLog(bytes, operationId, 'stderr'); await writeDiscoveryAsync(); await renderer.writeLogAsync(bytes, operationId, 'stderr'); }, - onEventAsync: (event) => renderer.writeEventAsync(event), - onQueuePositionAsync: process.stderr.isTTY + onEventAsync: async (event) => + agentRenderer ? agentRenderer.onEvent(event) : renderer.writeEventAsync(event), + onQueuePositionAsync: agentRenderer + ? async (position) => agentRenderer.onQueuePosition(position) + : process.stderr.isTTY ? (position) => writeStreamAsync( process.stderr, @@ -186,6 +215,7 @@ export async function launchClientAsync(rushx: boolean): Promise { } } if (outcome.kind === 'result') { + agentRenderer?.finish(outcome.result); process.exitCode = outcome.result.exitCode; if (outcome.result.admissionErrorCode) { await writeStreamAsync( @@ -194,10 +224,13 @@ export async function launchClientAsync(rushx: boolean): Promise { ); } } else if (outcome.kind === 'rejected') { + agentRenderer?.dispose(); throw new Error(`Daemon rejected the request (${outcome.rejection.code}): ${outcome.rejection.message}`); } else if (abort.signal.aborted) { + agentRenderer?.dispose('cancelled'); process.exitCode = 130; } else { + agentRenderer?.dispose(); process.stderr.write(`rush-client: ${outcome.message ?? outcome.reason}; using in-process Rush.\n`); launchInProcess(route.argv, rushx, selectedVersion); } diff --git a/apps/rush-cli-client/src/outputSelection.ts b/apps/rush-cli-client/src/outputSelection.ts new file mode 100644 index 0000000000..4aca93c3d2 --- /dev/null +++ b/apps/rush-cli-client/src/outputSelection.ts @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Keep this module free of heavy imports: start.ts loads it before @microsoft/rush-lib. + +/** Environment variable that selects the rush-client output mode: `agent` or `legacy`. */ +export const RUSHD_OUTPUT_ENV_VAR: 'RUSHD_OUTPUT' = 'RUSHD_OUTPUT'; + +/** + * Agent markers that auto-select agent output. This intentionally matches the default of + * `detectAgent()` in `@rushstack/reporter` (libraries/reporter/src/config/AgentDetection.ts). + */ +const AGENT_MARKERS: readonly string[] = ['COPILOT_CLI']; +const INACTIVE_VALUES: ReadonlySet = new Set(['', '0', 'false', 'no', 'off']); +const AI_REPORTER: 'ai' = 'ai'; + +export type ClientOutputMode = 'agent' | 'legacy'; + +function isActive(value: string | undefined): boolean { + return value !== undefined && !INACTIVE_VALUES.has(value.trim().toLowerCase()); +} + +/** + * Returns the value of the first `--reporter` flag before any `--` separator, if present. + */ +export function readReporterFlag(argv: ReadonlyArray): string | undefined { + for (let i: number = 0; i < argv.length; i++) { + const arg: string = argv[i]; + if (arg === '--') { + return undefined; + } + if (arg === '--reporter') { + return argv[i + 1]; + } + if (arg.startsWith('--reporter=')) { + return arg.slice('--reporter='.length); + } + } + return undefined; +} + +/** + * Returns true when the AI reporter was explicitly requested with `--reporter=ai` or `RUSH_REPORTER=ai`. + * On the daemon path this selects the agent output instead of forcing in-process Rush. + */ +export function isAiReporterRequested( + argv: ReadonlyArray, + environment: Readonly> +): boolean { + const flag: string | undefined = readReporterFlag(argv); + if (flag !== undefined) { + return flag.trim().toLowerCase() === AI_REPORTER; + } + return environment.RUSH_REPORTER?.trim().toLowerCase() === AI_REPORTER; +} + +/** + * Selects the rush-client output mode. Precedence: + * 1. `RUSHD_OUTPUT=agent|legacy` + * 2. `--reporter=ai` (or `RUSH_REPORTER=ai`) selects `agent` + * 3. an active agent marker (`COPILOT_CLI`) selects `agent` + * 4. otherwise `legacy` (the unchanged default output) + */ +export function selectClientOutputMode( + argv: ReadonlyArray, + environment: Readonly> +): ClientOutputMode { + const explicit: string | undefined = environment[RUSHD_OUTPUT_ENV_VAR]?.trim().toLowerCase(); + if (explicit === 'agent' || explicit === 'legacy') { + return explicit; + } + if (isAiReporterRequested(argv, environment)) { + return 'agent'; + } + return AGENT_MARKERS.some((name) => isActive(environment[name])) ? 'agent' : 'legacy'; +} diff --git a/apps/rush-cli-client/src/routing.ts b/apps/rush-cli-client/src/routing.ts index 310960249b..868291c9af 100644 --- a/apps/rush-cli-client/src/routing.ts +++ b/apps/rush-cli-client/src/routing.ts @@ -5,6 +5,7 @@ import type { IDaemonRequestAdmissionOptions } from '@rushstack/rush-daemon-prot import { RushXCommand, type IRushXCommandLineArguments } from '@microsoft/rush-lib'; import { parseClientAdmissionControls, type IClientAdmissionControls } from './ClientAdmissionControls'; +import { isAiReporterRequested } from './outputSelection'; const neverDaemonize: ReadonlySet = new Set([ 'add', @@ -33,6 +34,8 @@ export interface IClientRouteOptions { readonly enabled: boolean; readonly rushx: boolean; readonly hasTerminal?: boolean; + /** The repository's experiments.json `useRushReporter` opt-in. */ + readonly useRushReporter?: boolean; } export interface IClientRoute { @@ -42,14 +45,34 @@ export interface IClientRoute { readonly admission: IDaemonRequestAdmissionOptions | undefined; } +/** Removes --reporter ai / --reporter=ai; the daemon renders through the client's agent output instead. */ +function stripAiReporterFlag(args: ReadonlyArray, aiReporter: boolean): ReadonlyArray { + if (!aiReporter) return args; + const result: string[] = []; + for (let i: number = 0; i < args.length; i++) { + if (args[i] === '--reporter') { + i++; + } else if (!args[i].startsWith('--reporter=')) { + result.push(args[i]); + } + } + return result; +} + /** Routing never parses action parameters or relabels a custom command as a built-in. */ export function selectClientRoute(options: IClientRouteOptions): IClientRoute { const controls: IClientAdmissionControls = parseClientAdmissionControls(options.argv); const separator: number = controls.argv.indexOf('--'); const prefix: ReadonlyArray = separator < 0 ? controls.argv : controls.argv.slice(0, separator); const noDaemon: boolean = prefix.includes('--no-daemon'); + // An explicit AI reporter selects the client's agent output on the daemon path instead of in-process Rush. + const aiReporter: boolean = !options.rushx && isAiReporterRequested(prefix, options.environment); + const routedPrefix: ReadonlyArray = stripAiReporterFlag( + prefix.filter((arg) => arg !== '--no-daemon'), + aiReporter + ); const argv: ReadonlyArray = [ - ...prefix.filter((arg) => arg !== '--no-daemon'), + ...routedPrefix, ...(separator < 0 ? [] : controls.argv.slice(separator)) ]; const rushxArguments: IRushXCommandLineArguments | undefined = options.rushx @@ -58,11 +81,16 @@ export function selectClientRoute(options: IClientRouteOptions): IClientRoute { const commandName: string | undefined = rushxArguments ? rushxArguments.commandName || undefined : argv[0]; const reporterControls: boolean = options.environment.RUSH_LOG_LEVEL !== undefined || - (options.environment.RUSH_REPORTER !== undefined && options.environment.RUSH_REPORTER !== 'legacy') || + (!aiReporter && + options.environment.RUSH_REPORTER !== undefined && + options.environment.RUSH_REPORTER !== 'legacy') || (!options.rushx && - prefix.some((arg) => + routedPrefix.some((arg) => ['--reporter', '--output', '--log-level'].some((name) => arg === name || arg.startsWith(`${name}=`)) - )); + )) || + // The repository opted into the native reporter (experiments.json useRushReporter); only an + // explicit AI reporter request keeps such a repository on the daemon path. + (!options.rushx && !!options.useRushReporter && !aiReporter); const ci: boolean = ['CI', 'TF_BUILD', 'GITHUB_ACTIONS', 'JENKINS_URL', 'TEAMCITY_VERSION'].some((key) => { const value: string | undefined = options.environment[key]; return value !== undefined && value !== '' && value !== '0' && value !== 'false'; diff --git a/apps/rush-cli-client/src/start.ts b/apps/rush-cli-client/src/start.ts index d940f3f82a..d5a2f6a7af 100644 --- a/apps/rush-cli-client/src/start.ts +++ b/apps/rush-cli-client/src/start.ts @@ -1,9 +1,34 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { launchClientAsync } from './launchClient'; +import { AgentProgressRenderer } from './AgentProgressRenderer'; +import { selectClientOutputMode } from './outputSelection'; -launchClientAsync(false).catch((error: Error) => { +const startTimeMs: number = Date.now(); +const argv: string[] = process.argv.slice(2); +const commandName: string | undefined = argv[0]; +// Write the agent status line before loading @microsoft/rush-lib (hundreds of milliseconds). +const agentRenderer: AgentProgressRenderer | undefined = + selectClientOutputMode(argv, process.env) === 'agent' && + commandName !== undefined && + !commandName.startsWith('-') && + commandName !== 'daemon' && + !process.argv.includes('--help') && + !process.argv.includes('-h') + ? new AgentProgressRenderer({ + commandName, + isTTY: !!process.stdout.isTTY && process.env.TERM !== 'dumb', + columns: process.stdout.columns || 80, + write: (text: string) => process.stdout.write(text), + startTimeMs + }) + : undefined; +agentRenderer?.start(); + +const { launchClientAsync } = require('./launchClient') as typeof import('./launchClient'); + +launchClientAsync(false, agentRenderer).catch((error: Error) => { + agentRenderer?.dispose(); process.stderr.write(`rush-client: ${error.message}\n`); process.exitCode = 1; }); diff --git a/apps/rush-cli-client/src/test/AgentProgressRenderer.test.ts b/apps/rush-cli-client/src/test/AgentProgressRenderer.test.ts new file mode 100644 index 0000000000..a0449bc1e2 --- /dev/null +++ b/apps/rush-cli-client/src/test/AgentProgressRenderer.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + DAEMON_PROTOCOL_VERSION, + type DaemonEventType, + type IDaemonEventEnvelope +} from '@rushstack/rush-daemon-protocol'; + +import { AgentProgressRenderer } from '../AgentProgressRenderer'; + +function event(type: DaemonEventType, payload: unknown): IDaemonEventEnvelope { + return { + eventId: 'event', + sessionId: 'session', + sequence: 1, + timestamp: new Date().toISOString(), + protocolVersion: DAEMON_PROTOCOL_VERSION, + source: { packageName: 'test', packageVersion: '1.0.0' }, + privacy: 'public', + required: false, + type, + payload + }; +} + +const ANSI_ESCAPE: RegExp = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[A-Za-z]`, 'g'); + +function createRenderer(isTTY: boolean): { renderer: AgentProgressRenderer; output: string[]; clock: { ms: number } } { + const output: string[] = []; + const clock: { ms: number } = { ms: 0 }; + const renderer: AgentProgressRenderer = new AgentProgressRenderer({ + commandName: 'build', + isTTY, + columns: 60, + write: (text: string) => output.push(text), + now: () => clock.ms, + startTimeMs: 0 + }); + return { renderer, output, clock }; +} + +function status(operationId: string, value: string): IDaemonEventEnvelope { + return event('operationStatusChanged', { operationId, previousStatus: 'READY', status: value }); +} + +describe(AgentProgressRenderer.name, () => { + it('writes a first line immediately and a bounded summary for a successful build (pipe)', () => { + const { renderer, output, clock } = createRenderer(false); + renderer.start(); + expect(output).toEqual(['rush build · 0.0s · connecting to rushd (auto-starts if needed)\n']); + renderer.onEvent(event('operationRegistered', { operationId: 'a (build)', silent: false })); + renderer.onEvent(event('operationRegistered', { operationId: 'b (build)', silent: false })); + renderer.onEvent(event('operationRegistered', { operationId: 'hidden', silent: true })); + renderer.onEvent(status('a (build)', 'EXECUTING')); + renderer.onLog(Buffer.from('noise\n'), 'a (build)', 'stdout'); + clock.ms = 2500; + renderer.onEvent(status('a (build)', 'SUCCESS')); + renderer.onEvent(status('b (build)', 'SKIPPED')); + clock.ms = 3000; + renderer.finish({ exitCode: 0 }); + renderer.dispose(); + expect(output.join('')).not.toContain('noise'); + expect(output[output.length - 1]).toBe( + 'rush build: SUCCESS 2/2 operations (1 success, 1 skipped) in 3.0s\n' + ); + expect(output.length).toBeLessThanOrEqual(4); + }); + + it('reports an up-to-date request instead of printing nothing', () => { + const { renderer, output } = createRenderer(false); + renderer.finish({ exitCode: 0 }); + expect(output).toEqual(['rush build: SUCCESS up to date (no operations needed) in 0.0s\n']); + }); + + it('lists failed operations and a bounded stderr tail on failure', () => { + const { renderer, output } = createRenderer(false); + renderer.onEvent(status('p05 (build)', 'EXECUTING')); + for (let i = 0; i < 20; i++) { + renderer.onLog(Buffer.from(`error ${i}\n`), 'p05 (build)', 'stderr'); + } + renderer.onEvent(status('p05 (build)', 'FAILURE')); + renderer.onEvent(status('p06 (build)', 'BLOCKED')); + renderer.finish({ exitCode: 1 }); + const text: string = output.join(''); + expect(text).toContain('rush build: FAILURE 2/2 operations (1 failure, 1 blocked) in 0.0s · failed: p05 (build)\n'); + expect(text).toContain(' p05 (build): error 0\n'); + expect(text).not.toContain('error 10'); + }); + + it('shows the stdout tail of a failed operation that reported errors on stdout', () => { + const { renderer, output } = createRenderer(false); + renderer.onEvent(status('ok (build)', 'EXECUTING')); + renderer.onLog(Buffer.from('ok noise\n'), 'ok (build)', 'stdout'); + renderer.onEvent(status('ok (build)', 'SUCCESS')); + renderer.onEvent(status('tsc (build)', 'EXECUTING')); + renderer.onLog(Buffer.from('src/x.ts(1,1): error TS1005: stdout-error\n'), 'tsc (build)', 'stdout'); + renderer.onEvent(status('tsc (build)', 'FAILURE')); + renderer.finish({ exitCode: 1 }); + const text: string = output.join(''); + expect(text).toContain(' tsc (build): src/x.ts(1,1): error TS1005: stdout-error\n'); + expect(text).not.toContain('ok noise'); + }); + + it('shows queue position immediately', () => { + const { renderer, output } = createRenderer(false); + renderer.onQueuePosition(2); + expect(output[0]).toContain('queued behind another request (position 2)'); + }); + + it('writes a final summary line after a queued request completes', () => { + const { renderer, output, clock } = createRenderer(false); + renderer.start(); + renderer.onQueuePosition(1); + clock.ms = 4000; + renderer.finish({ exitCode: 0 }); + expect(output[output.length - 1]).toBe('rush build: SUCCESS up to date (no operations needed) in 4.0s\n'); + }); + + it('throttles progress lines on a pipe', () => { + const { renderer, output, clock } = createRenderer(false); + renderer.start(); + for (let i = 0; i < 50; i++) { + clock.ms = i * 10; + renderer.onEvent(status(`p${i} (build)`, 'EXECUTING')); + } + expect(output).toHaveLength(1); + clock.ms = 2500; + renderer.onEvent(status('p0 (build)', 'SUCCESS')); + expect(output).toHaveLength(2); + clock.ms = 3000; + renderer.onEvent(status('p1 (build)', 'SUCCESS')); + expect(output).toHaveLength(2); + renderer.dispose(); + }); + + it('renders at most three live rows on a TTY and clears them before the summary', () => { + const { renderer, output } = createRenderer(true); + renderer.start(); + renderer.onEvent(status('a-very-long-project-name-that-will-not-fit (build)', 'EXECUTING')); + renderer.finish({ exitCode: 0 }); + const frames: string[] = output.slice(0, -2); + for (const frame of frames) { + const rows: string[] = frame.replace(ANSI_ESCAPE, '').split('\n'); + expect(rows.length).toBe(4); // three rows plus the trailing newline + for (const row of rows) { + expect(row.length).toBeLessThanOrEqual(59); + } + } + expect(output[output.length - 2]).toBe('\x1b[3A\x1b[0J\x1b[?25h'); + expect(output[output.length - 1]).toContain('rush build: SUCCESS'); + }); +}); diff --git a/apps/rush-cli-client/src/test/outputSelection.test.ts b/apps/rush-cli-client/src/test/outputSelection.test.ts new file mode 100644 index 0000000000..e53dd9b448 --- /dev/null +++ b/apps/rush-cli-client/src/test/outputSelection.test.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { selectClientOutputMode } from '../outputSelection'; + +describe(selectClientOutputMode.name, () => { + it.each([ + { argv: ['build'], environment: {}, mode: 'legacy' }, + { argv: ['build'], environment: { RUSHD_OUTPUT: 'agent' }, mode: 'agent' }, + { argv: ['build'], environment: { RUSHD_OUTPUT: ' AGENT ' }, mode: 'agent' }, + { argv: ['build'], environment: { COPILOT_CLI: '1' }, mode: 'agent' }, + { argv: ['build'], environment: { COPILOT_CLI: 'false' }, mode: 'legacy' }, + { argv: ['build'], environment: { COPILOT_CLI: '1', RUSHD_OUTPUT: 'legacy' }, mode: 'legacy' }, + { argv: ['build', '--reporter=ai'], environment: {}, mode: 'agent' }, + { argv: ['build', '--reporter', 'ai'], environment: {}, mode: 'agent' }, + { argv: ['build', '--reporter=ai'], environment: { RUSHD_OUTPUT: 'legacy' }, mode: 'legacy' }, + { argv: ['build'], environment: { RUSH_REPORTER: 'ai' }, mode: 'agent' }, + { argv: ['build', '--reporter=json'], environment: { RUSH_REPORTER: 'ai' }, mode: 'legacy' }, + { argv: ['build', '--', '--reporter=ai'], environment: {}, mode: 'legacy' }, + { argv: ['build'], environment: { CLAUDECODE: '1' }, mode: 'legacy' } + ])('selects $mode for $argv with $environment', ({ argv, environment, mode }) => { + expect(selectClientOutputMode(argv, environment)).toBe(mode); + }); +}); diff --git a/apps/rush-cli-client/src/test/routing.test.ts b/apps/rush-cli-client/src/test/routing.test.ts index b71a98a4e6..d8e9c89e52 100644 --- a/apps/rush-cli-client/src/test/routing.test.ts +++ b/apps/rush-cli-client/src/test/routing.test.ts @@ -23,11 +23,29 @@ describe('opt-in routing', () => { { argv: ['publish'], enabled: true, environment: { RUSH_DAEMON: '1' }, daemon: false }, { argv: ['daemon', 'status'], enabled: true, environment: {}, daemon: false }, { argv: ['--help'], enabled: true, environment: {}, daemon: false }, - { argv: ['build', '--help'], enabled: true, environment: {}, daemon: false } + { argv: ['build', '--help'], enabled: true, environment: {}, daemon: false }, + { argv: ['build', '--reporter=ai'], enabled: true, environment: {}, daemon: true }, + { argv: ['build', '--reporter', 'ai'], enabled: true, environment: {}, daemon: true }, + { argv: ['build'], enabled: true, environment: { RUSH_REPORTER: 'ai' }, daemon: true }, + { argv: ['build', '--reporter=json'], enabled: true, environment: { RUSH_REPORTER: 'ai' }, daemon: false }, + { argv: ['build', '--reporter=ai', '--output', 'x'], enabled: true, environment: {}, daemon: false }, + { argv: ['build'], enabled: true, environment: {}, useRushReporter: true, daemon: false }, + { argv: ['build', '--reporter=ai'], enabled: true, environment: {}, useRushReporter: true, daemon: true } ])('selects $daemon for $argv', ({ daemon, ...options }) => { expect(selectClientRoute({ ...options, rushx: false }).daemon).toBe(daemon); }); + it('strips an explicit AI reporter flag from the daemon request', () => { + expect( + selectClientRoute({ argv: ['build', '--reporter', 'ai', '-t', 'a'], enabled: true, environment: {}, rushx: false }) + .argv + ).toEqual(['build', '-t', 'a']); + expect( + selectClientRoute({ argv: ['build', '--', '--reporter=ai'], enabled: true, environment: {}, rushx: false }) + .argv + ).toEqual(['build', '--', '--reporter=ai']); + }); + it('preserves script arguments after -- and permits scripts named like built-ins', () => { expect( selectClientRoute({ diff --git a/common/changes/@rushstack/rush-cli-client/agent-reporter_2026-09-24-02-35.json b/common/changes/@rushstack/rush-cli-client/agent-reporter_2026-09-24-02-35.json new file mode 100644 index 0000000000..2d8320a7cd --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/agent-reporter_2026-09-24-02-35.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Add an opt-in agent output mode on the daemon path (RUSHD_OUTPUT=agent, --reporter=ai, RUSH_REPORTER=ai, or COPILOT_CLI) with an immediate first line, bounded live progress, and a guaranteed final summary; keep repositories that opt into useRushReporter on the native reporter path.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-cli-client", + "email": "selarkin@microsoft.com" +} From 4720e26e6fb40435342a7f5032ce4e2ce337ec6c Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 20:23:13 -0700 Subject: [PATCH 2/3] [rush-cli-client] Keep explicit reporter controls on the native path; agent mode only when no reporter is chosen Addresses review: --reporter=ai (with or without --no-daemon, and on fallback) and RUSH_REPORTER=ai keep the native AI reporter JSON; agent mode writes nothing ahead of native reporter output. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/rush-cli-client/README.md | 37 +++---- apps/rush-cli-client/src/launchClient.ts | 14 +-- apps/rush-cli-client/src/outputSelection.ts | 103 +++++++++++------- apps/rush-cli-client/src/routing.ts | 35 +----- apps/rush-cli-client/src/start.ts | 9 +- .../src/test/outputSelection.test.ts | 62 +++++++++-- apps/rush-cli-client/src/test/routing.test.ts | 27 ++--- .../agent-reporter_2026-09-24-02-35.json | 2 +- 8 files changed, 166 insertions(+), 123 deletions(-) diff --git a/apps/rush-cli-client/README.md b/apps/rush-cli-client/README.md index 288df22b8f..5e773a7cf9 100644 --- a/apps/rush-cli-client/README.md +++ b/apps/rush-cli-client/README.md @@ -51,38 +51,37 @@ Admission controls also apply to experimental graph requests, but not retains native command behavior. Waiting positions are shown on interactive stderr, and admission failures report their typed reason and a nonzero exit code. -Explicit reporter/output/log-level controls retain the native frontend reporter path. -The daemon client does not silently reinterpret requests for JSON, file, or other -reporter formats. The one exception is the AI reporter: `--reporter=ai` (or -`--reporter ai`, or `RUSH_REPORTER=ai`) stays on the daemon path, is removed from -the forwarded argv, and selects the client's agent output (see below). Combined with -other controls (`--output`, `--log-level`, `RUSH_LOG_LEVEL`) it stays native. +Explicit reporter/output/log-level controls (`--reporter`, `--output`, `--log-level`, +`RUSH_REPORTER` other than `legacy`, or `RUSH_LOG_LEVEL`) retain the native frontend +reporter path, with or without `--no-daemon`, including `--reporter=ai`. The daemon client +does not silently reinterpret requests for JSON, AI, file, or other reporter formats. A repository that opts into the native reporter with `"useRushReporter": true` in -`common/config/rush/experiments.json` also stays on the native (in-process) path so +`common/config/rush/experiments.json` also stays on the native (in-process) path, so that its reporter output is honored rather than silently replaced by the daemon -stream. Only an explicit AI reporter request keeps such a repository on the daemon -path. Native reporter rendering over the daemon protocol is a follow-up. +stream. Native reporter rendering over the daemon protocol is a follow-up. ### Output modes -The `rush-client` daemon path has two output modes (`rushx-client` always uses `legacy`). Selection precedence: +The `rush-client` daemon path has two output modes (`rushx-client` always uses `legacy`). +Requests that use the native reporter path (see above) always get native output, and +agent mode writes nothing ahead of it. Otherwise, selection precedence is: 1. `RUSHD_OUTPUT=agent` or `RUSHD_OUTPUT=legacy`. -2. An explicit AI reporter request (`--reporter=ai` or `RUSH_REPORTER=ai`) selects `agent`. -3. An active `COPILOT_CLI` agent marker selects `agent`, matching `detectAgent()` in +2. An active `COPILOT_CLI` agent marker selects `agent`, matching `detectAgent()` in `@rushstack/reporter` (a value is inactive when empty, `0`, `false`, `no` or `off`). Other agents can opt in with `RUSHD_OUTPUT=agent`. -4. Otherwise `legacy`: the unchanged collated operation stream. +3. Otherwise `legacy`: the unchanged collated operation stream. -Agent mode writes a first status line before `@microsoft/rush-lib` is loaded, then -at most three live rows on a TTY (append-only lines throttled to one per 2 seconds on -a pipe), the queue position when waiting for admission, and always one final summary -line (`rush build: SUCCESS 12/12 operations (...) in 3.1s`, or +Agent mode is plain text for humans and agents, not the AI reporter's JSON record format; +use `--reporter=ai` for machine-parsed records. It writes a first status line before +`@microsoft/rush-lib` is loaded, then at most three live rows on a TTY (append-only lines +throttled to one per 2 seconds on a pipe), the queue position when waiting for admission, +and always one final summary line (`rush build: SUCCESS 12/12 operations (...) in 3.1s`, or `up to date (no operations needed)`). On failure, it lists failed operations and a bounded tail (10 lines) of their stderr, or of their stdout when they wrote no stderr. -Operation logs are otherwise not printed; use `RUSHD_OUTPUT=legacy` for full logs. When a request falls back to in-process Rush, agent mode stops and -native output follows. +Operation logs are otherwise not printed; use `RUSHD_OUTPUT=legacy` for full logs. When +a request falls back to in-process Rush, agent mode stops and native output follows. Positively identified built-in `install` and `update` follow the same opt-in routing precedence as workspace builds and require protocol **0.10** diff --git a/apps/rush-cli-client/src/launchClient.ts b/apps/rush-cli-client/src/launchClient.ts index a2053a26cb..58c7d251db 100644 --- a/apps/rush-cli-client/src/launchClient.ts +++ b/apps/rush-cli-client/src/launchClient.ts @@ -29,6 +29,7 @@ import { executeDaemonCommandAsync } from './daemonCommands'; import { ClientOperationRenderer } from './ClientOperationRenderer'; import type { AgentProgressRenderer } from './AgentProgressRenderer'; import { getDaemonConnectionOptionsAsync } from './daemonConnectionOptions'; +import { readUseRushReporter } from './outputSelection'; import { selectClientRoute, type IClientRoute } from './routing'; import { writeStreamAsync } from './writeStreamAsync'; @@ -37,17 +38,6 @@ interface IWorkspaceJson { readonly daemon?: IDaemonConfigurationJson; } -/** Reads the repository's experiments.json `useRushReporter` opt-in. */ -function readUseRushReporter(rushJsonPath: string): boolean { - const experimentsPath: string = path.join(path.dirname(rushJsonPath), 'common', 'config', 'rush', 'experiments.json'); - try { - return (JsonFile.load(experimentsPath) as { useRushReporter?: unknown }).useRushReporter === true; - } catch { - // A missing or unreadable file means the opt-in is absent; in-process Rush reports invalid files. - return false; - } -} - export async function launchClientAsync( rushx: boolean, agentRenderer?: AgentProgressRenderer @@ -87,7 +77,7 @@ export async function launchClientAsync( return; } if (!route.daemon || !rushJsonPath || route.commandName === undefined) { - agentRenderer?.dispose('not routed to rushd; running in-process Rush'); + agentRenderer?.dispose(); launchInProcess(route.argv, rushx, selectedVersion); return; } diff --git a/apps/rush-cli-client/src/outputSelection.ts b/apps/rush-cli-client/src/outputSelection.ts index 4aca93c3d2..ed5c1e6a37 100644 --- a/apps/rush-cli-client/src/outputSelection.ts +++ b/apps/rush-cli-client/src/outputSelection.ts @@ -3,6 +3,9 @@ // Keep this module free of heavy imports: start.ts loads it before @microsoft/rush-lib. +import * as fs from 'node:fs'; +import * as path from 'node:path'; + /** Environment variable that selects the rush-client output mode: `agent` or `legacy`. */ export const RUSHD_OUTPUT_ENV_VAR: 'RUSHD_OUTPUT' = 'RUSHD_OUTPUT'; @@ -12,7 +15,7 @@ export const RUSHD_OUTPUT_ENV_VAR: 'RUSHD_OUTPUT' = 'RUSHD_OUTPUT'; */ const AGENT_MARKERS: readonly string[] = ['COPILOT_CLI']; const INACTIVE_VALUES: ReadonlySet = new Set(['', '0', 'false', 'no', 'off']); -const AI_REPORTER: 'ai' = 'ai'; +const NATIVE_REPORTER_FLAGS: readonly string[] = ['--reporter', '--output', '--log-level']; export type ClientOutputMode = 'agent' | 'legacy'; @@ -21,56 +24,82 @@ function isActive(value: string | undefined): boolean { } /** - * Returns the value of the first `--reporter` flag before any `--` separator, if present. + * Returns true when the invocation explicitly selects a reporter, output or log level + * (`--reporter`, `--output`, `--log-level` before `--`, `RUSH_REPORTER` other than `legacy`, + * or `RUSH_LOG_LEVEL`). Such requests always use the native reporter path. */ -export function readReporterFlag(argv: ReadonlyArray): string | undefined { - for (let i: number = 0; i < argv.length; i++) { - const arg: string = argv[i]; - if (arg === '--') { - return undefined; - } - if (arg === '--reporter') { - return argv[i + 1]; - } - if (arg.startsWith('--reporter=')) { - return arg.slice('--reporter='.length); - } +export function hasExplicitReporterControls( + argv: ReadonlyArray, + environment: Readonly> +): boolean { + if (environment.RUSH_LOG_LEVEL !== undefined) { + return true; + } + if (environment.RUSH_REPORTER !== undefined && environment.RUSH_REPORTER !== 'legacy') { + return true; } - return undefined; + const separator: number = argv.indexOf('--'); + const prefix: ReadonlyArray = separator < 0 ? argv : argv.slice(0, separator); + return prefix.some((arg) => NATIVE_REPORTER_FLAGS.some((name) => arg === name || arg.startsWith(`${name}=`))); } /** - * Returns true when the AI reporter was explicitly requested with `--reporter=ai` or `RUSH_REPORTER=ai`. - * On the daemon path this selects the agent output instead of forcing in-process Rush. + * Reads the `useRushReporter` opt-in from `common/config/rush/experiments.json` next to `rush.json`. + * A missing or unreadable file means the opt-in is absent; in-process Rush reports invalid files. */ -export function isAiReporterRequested( - argv: ReadonlyArray, - environment: Readonly> -): boolean { - const flag: string | undefined = readReporterFlag(argv); - if (flag !== undefined) { - return flag.trim().toLowerCase() === AI_REPORTER; +export function readUseRushReporter(rushJsonPath: string): boolean { + const experimentsPath: string = path.join(path.dirname(rushJsonPath), 'common', 'config', 'rush', 'experiments.json'); + let contents: string; + try { + contents = fs.readFileSync(experimentsPath, 'utf8'); + } catch { + return false; } - return environment.RUSH_REPORTER?.trim().toLowerCase() === AI_REPORTER; + const uncommented: string = contents.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + return /"useRushReporter"\s*:\s*true\b/.test(uncommented); +} + +/** Finds `rush.json` in `startingFolder` or an ancestor without loading `@microsoft/rush-lib`. */ +export function findRushJsonPath(startingFolder: string): string | undefined { + let folder: string = path.resolve(startingFolder); + for (;;) { + const candidate: string = path.join(folder, 'rush.json'); + if (fs.existsSync(candidate)) { + return candidate; + } + const parent: string = path.dirname(folder); + if (parent === folder) { + return undefined; + } + folder = parent; + } +} + +export interface IClientOutputModeOptions { + readonly argv: ReadonlyArray; + readonly environment: Readonly>; + /** Whether the repository opted into the native reporter (experiments.json `useRushReporter`). */ + readonly useRushReporter?: boolean; } /** - * Selects the rush-client output mode. Precedence: + * Selects the rush-client output mode. Requests that will use the native reporter path + * (explicit reporter controls, `--no-daemon`, or a `useRushReporter` repository) always use `legacy`, + * so that nothing is written ahead of native reporter output. Otherwise: * 1. `RUSHD_OUTPUT=agent|legacy` - * 2. `--reporter=ai` (or `RUSH_REPORTER=ai`) selects `agent` - * 3. an active agent marker (`COPILOT_CLI`) selects `agent` - * 4. otherwise `legacy` (the unchanged default output) + * 2. an active agent marker (`COPILOT_CLI`) selects `agent` + * 3. otherwise `legacy` (the unchanged default output) */ -export function selectClientOutputMode( - argv: ReadonlyArray, - environment: Readonly> -): ClientOutputMode { +export function selectClientOutputMode(options: IClientOutputModeOptions): ClientOutputMode { + const { argv, environment } = options; + const separator: number = argv.indexOf('--'); + const prefix: ReadonlyArray = separator < 0 ? argv : argv.slice(0, separator); + if (options.useRushReporter || prefix.includes('--no-daemon') || hasExplicitReporterControls(argv, environment)) { + return 'legacy'; + } const explicit: string | undefined = environment[RUSHD_OUTPUT_ENV_VAR]?.trim().toLowerCase(); if (explicit === 'agent' || explicit === 'legacy') { return explicit; } - if (isAiReporterRequested(argv, environment)) { - return 'agent'; - } return AGENT_MARKERS.some((name) => isActive(environment[name])) ? 'agent' : 'legacy'; -} +} \ No newline at end of file diff --git a/apps/rush-cli-client/src/routing.ts b/apps/rush-cli-client/src/routing.ts index 868291c9af..c8a1c53b3f 100644 --- a/apps/rush-cli-client/src/routing.ts +++ b/apps/rush-cli-client/src/routing.ts @@ -5,7 +5,6 @@ import type { IDaemonRequestAdmissionOptions } from '@rushstack/rush-daemon-prot import { RushXCommand, type IRushXCommandLineArguments } from '@microsoft/rush-lib'; import { parseClientAdmissionControls, type IClientAdmissionControls } from './ClientAdmissionControls'; -import { isAiReporterRequested } from './outputSelection'; const neverDaemonize: ReadonlySet = new Set([ 'add', @@ -34,7 +33,7 @@ export interface IClientRouteOptions { readonly enabled: boolean; readonly rushx: boolean; readonly hasTerminal?: boolean; - /** The repository's experiments.json `useRushReporter` opt-in. */ + /** The repository's experiments.json `useRushReporter` opt-in; such requests use the native reporter. */ readonly useRushReporter?: boolean; } @@ -45,34 +44,14 @@ export interface IClientRoute { readonly admission: IDaemonRequestAdmissionOptions | undefined; } -/** Removes --reporter ai / --reporter=ai; the daemon renders through the client's agent output instead. */ -function stripAiReporterFlag(args: ReadonlyArray, aiReporter: boolean): ReadonlyArray { - if (!aiReporter) return args; - const result: string[] = []; - for (let i: number = 0; i < args.length; i++) { - if (args[i] === '--reporter') { - i++; - } else if (!args[i].startsWith('--reporter=')) { - result.push(args[i]); - } - } - return result; -} - /** Routing never parses action parameters or relabels a custom command as a built-in. */ export function selectClientRoute(options: IClientRouteOptions): IClientRoute { const controls: IClientAdmissionControls = parseClientAdmissionControls(options.argv); const separator: number = controls.argv.indexOf('--'); const prefix: ReadonlyArray = separator < 0 ? controls.argv : controls.argv.slice(0, separator); const noDaemon: boolean = prefix.includes('--no-daemon'); - // An explicit AI reporter selects the client's agent output on the daemon path instead of in-process Rush. - const aiReporter: boolean = !options.rushx && isAiReporterRequested(prefix, options.environment); - const routedPrefix: ReadonlyArray = stripAiReporterFlag( - prefix.filter((arg) => arg !== '--no-daemon'), - aiReporter - ); const argv: ReadonlyArray = [ - ...routedPrefix, + ...prefix.filter((arg) => arg !== '--no-daemon'), ...(separator < 0 ? [] : controls.argv.slice(separator)) ]; const rushxArguments: IRushXCommandLineArguments | undefined = options.rushx @@ -81,16 +60,12 @@ export function selectClientRoute(options: IClientRouteOptions): IClientRoute { const commandName: string | undefined = rushxArguments ? rushxArguments.commandName || undefined : argv[0]; const reporterControls: boolean = options.environment.RUSH_LOG_LEVEL !== undefined || - (!aiReporter && - options.environment.RUSH_REPORTER !== undefined && - options.environment.RUSH_REPORTER !== 'legacy') || + (options.environment.RUSH_REPORTER !== undefined && options.environment.RUSH_REPORTER !== 'legacy') || (!options.rushx && - routedPrefix.some((arg) => + prefix.some((arg) => ['--reporter', '--output', '--log-level'].some((name) => arg === name || arg.startsWith(`${name}=`)) )) || - // The repository opted into the native reporter (experiments.json useRushReporter); only an - // explicit AI reporter request keeps such a repository on the daemon path. - (!options.rushx && !!options.useRushReporter && !aiReporter); + (!options.rushx && !!options.useRushReporter); const ci: boolean = ['CI', 'TF_BUILD', 'GITHUB_ACTIONS', 'JENKINS_URL', 'TEAMCITY_VERSION'].some((key) => { const value: string | undefined = options.environment[key]; return value !== undefined && value !== '' && value !== '0' && value !== 'false'; diff --git a/apps/rush-cli-client/src/start.ts b/apps/rush-cli-client/src/start.ts index d5a2f6a7af..56ed84028c 100644 --- a/apps/rush-cli-client/src/start.ts +++ b/apps/rush-cli-client/src/start.ts @@ -2,14 +2,19 @@ // See LICENSE in the project root for license information. import { AgentProgressRenderer } from './AgentProgressRenderer'; -import { selectClientOutputMode } from './outputSelection'; +import { findRushJsonPath, readUseRushReporter, selectClientOutputMode } from './outputSelection'; const startTimeMs: number = Date.now(); const argv: string[] = process.argv.slice(2); const commandName: string | undefined = argv[0]; +const rushJsonPath: string | undefined = findRushJsonPath(process.cwd()); // Write the agent status line before loading @microsoft/rush-lib (hundreds of milliseconds). const agentRenderer: AgentProgressRenderer | undefined = - selectClientOutputMode(argv, process.env) === 'agent' && + selectClientOutputMode({ + argv, + environment: process.env, + useRushReporter: !!rushJsonPath && readUseRushReporter(rushJsonPath) + }) === 'agent' && commandName !== undefined && !commandName.startsWith('-') && commandName !== 'daemon' && diff --git a/apps/rush-cli-client/src/test/outputSelection.test.ts b/apps/rush-cli-client/src/test/outputSelection.test.ts index e53dd9b448..e1bd469af6 100644 --- a/apps/rush-cli-client/src/test/outputSelection.test.ts +++ b/apps/rush-cli-client/src/test/outputSelection.test.ts @@ -1,7 +1,11 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { selectClientOutputMode } from '../outputSelection'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { findRushJsonPath, readUseRushReporter, selectClientOutputMode } from '../outputSelection'; describe(selectClientOutputMode.name, () => { it.each([ @@ -11,14 +15,54 @@ describe(selectClientOutputMode.name, () => { { argv: ['build'], environment: { COPILOT_CLI: '1' }, mode: 'agent' }, { argv: ['build'], environment: { COPILOT_CLI: 'false' }, mode: 'legacy' }, { argv: ['build'], environment: { COPILOT_CLI: '1', RUSHD_OUTPUT: 'legacy' }, mode: 'legacy' }, - { argv: ['build', '--reporter=ai'], environment: {}, mode: 'agent' }, - { argv: ['build', '--reporter', 'ai'], environment: {}, mode: 'agent' }, - { argv: ['build', '--reporter=ai'], environment: { RUSHD_OUTPUT: 'legacy' }, mode: 'legacy' }, - { argv: ['build'], environment: { RUSH_REPORTER: 'ai' }, mode: 'agent' }, - { argv: ['build', '--reporter=json'], environment: { RUSH_REPORTER: 'ai' }, mode: 'legacy' }, - { argv: ['build', '--', '--reporter=ai'], environment: {}, mode: 'legacy' }, - { argv: ['build'], environment: { CLAUDECODE: '1' }, mode: 'legacy' } + { argv: ['build'], environment: { CLAUDECODE: '1' }, mode: 'legacy' }, + { argv: ['build'], environment: { COPILOT_CLI: '1', RUSH_REPORTER: 'legacy' }, mode: 'agent' }, + // Explicit reporter controls always keep the native reporter path, and nothing is written ahead of it. + { argv: ['build', '--reporter=ai'], environment: { RUSHD_OUTPUT: 'agent' }, mode: 'legacy' }, + { argv: ['build', '--reporter', 'ai'], environment: { COPILOT_CLI: '1' }, mode: 'legacy' }, + { argv: ['build', '--reporter=ai', '--no-daemon'], environment: { COPILOT_CLI: '1' }, mode: 'legacy' }, + { argv: ['build', '--no-daemon'], environment: { RUSHD_OUTPUT: 'agent' }, mode: 'legacy' }, + { argv: ['build', '--output', 'x.log'], environment: { RUSHD_OUTPUT: 'agent' }, mode: 'legacy' }, + { argv: ['build'], environment: { RUSHD_OUTPUT: 'agent', RUSH_REPORTER: 'ai' }, mode: 'legacy' }, + { argv: ['build'], environment: { RUSHD_OUTPUT: 'agent', RUSH_LOG_LEVEL: 'debug' }, mode: 'legacy' }, + { argv: ['build', '--', '--reporter=ai'], environment: { RUSHD_OUTPUT: 'agent' }, mode: 'agent' } ])('selects $mode for $argv with $environment', ({ argv, environment, mode }) => { - expect(selectClientOutputMode(argv, environment)).toBe(mode); + expect(selectClientOutputMode({ argv, environment })).toBe(mode); + }); + + it('keeps useRushReporter repositories on the native reporter output', () => { + expect( + selectClientOutputMode({ argv: ['build'], environment: { COPILOT_CLI: '1' }, useRushReporter: true }) + ).toBe('legacy'); }); }); + +describe(readUseRushReporter.name, () => { + let folder: string; + beforeEach(() => { + folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-output-selection-')); + fs.writeFileSync(path.join(folder, 'rush.json'), '{}'); + fs.mkdirSync(path.join(folder, 'common', 'config', 'rush'), { recursive: true }); + }); + afterEach(() => fs.rmSync(folder, { recursive: true, force: true })); + + function write(contents: string): void { + fs.writeFileSync(path.join(folder, 'common', 'config', 'rush', 'experiments.json'), contents); + } + + it('reads the opt-in and ignores commented-out settings', () => { + const rushJsonPath: string = path.join(folder, 'rush.json'); + expect(readUseRushReporter(rushJsonPath)).toBe(false); + write('{\n // "useRushReporter": true,\n /* "useRushReporter": true */\n}\n'); + expect(readUseRushReporter(rushJsonPath)).toBe(false); + write('{ "useRushReporter": false }'); + expect(readUseRushReporter(rushJsonPath)).toBe(false); + write('{\n "useRushReporter": true\n}\n'); + expect(readUseRushReporter(rushJsonPath)).toBe(true); + }); + + it('finds rush.json from a nested folder', () => { + const nested: string = path.join(folder, 'common', 'config'); + expect(findRushJsonPath(nested)).toBe(path.join(folder, 'rush.json')); + }); +}); \ No newline at end of file diff --git a/apps/rush-cli-client/src/test/routing.test.ts b/apps/rush-cli-client/src/test/routing.test.ts index d8e9c89e52..4b41765cdd 100644 --- a/apps/rush-cli-client/src/test/routing.test.ts +++ b/apps/rush-cli-client/src/test/routing.test.ts @@ -24,26 +24,27 @@ describe('opt-in routing', () => { { argv: ['daemon', 'status'], enabled: true, environment: {}, daemon: false }, { argv: ['--help'], enabled: true, environment: {}, daemon: false }, { argv: ['build', '--help'], enabled: true, environment: {}, daemon: false }, - { argv: ['build', '--reporter=ai'], enabled: true, environment: {}, daemon: true }, - { argv: ['build', '--reporter', 'ai'], enabled: true, environment: {}, daemon: true }, - { argv: ['build'], enabled: true, environment: { RUSH_REPORTER: 'ai' }, daemon: true }, - { argv: ['build', '--reporter=json'], enabled: true, environment: { RUSH_REPORTER: 'ai' }, daemon: false }, - { argv: ['build', '--reporter=ai', '--output', 'x'], enabled: true, environment: {}, daemon: false }, + { argv: ['build', '--reporter=ai'], enabled: true, environment: {}, daemon: false }, + { argv: ['build', '--reporter', 'ai'], enabled: true, environment: {}, daemon: false }, + { argv: ['build', '--reporter=ai', '--no-daemon'], enabled: true, environment: {}, daemon: false }, + { argv: ['build'], enabled: true, environment: { RUSH_REPORTER: 'ai' }, daemon: false }, { argv: ['build'], enabled: true, environment: {}, useRushReporter: true, daemon: false }, - { argv: ['build', '--reporter=ai'], enabled: true, environment: {}, useRushReporter: true, daemon: true } + { argv: ['build'], enabled: true, environment: {}, useRushReporter: false, daemon: true } ])('selects $daemon for $argv', ({ daemon, ...options }) => { expect(selectClientRoute({ ...options, rushx: false }).daemon).toBe(daemon); }); - it('strips an explicit AI reporter flag from the daemon request', () => { + it('forwards an explicit AI reporter flag unchanged to the native path', () => { expect( - selectClientRoute({ argv: ['build', '--reporter', 'ai', '-t', 'a'], enabled: true, environment: {}, rushx: false }) - .argv - ).toEqual(['build', '-t', 'a']); + selectClientRoute({ argv: ['build', '--reporter', 'ai'], enabled: true, environment: {}, rushx: false }) + ).toMatchObject({ argv: ['build', '--reporter', 'ai'], daemon: false }); + }); + + it('ignores useRushReporter for rushx scripts', () => { expect( - selectClientRoute({ argv: ['build', '--', '--reporter=ai'], enabled: true, environment: {}, rushx: false }) - .argv - ).toEqual(['build', '--', '--reporter=ai']); + selectClientRoute({ argv: ['build'], enabled: true, environment: {}, rushx: true, useRushReporter: true }) + .daemon + ).toBe(true); }); it('preserves script arguments after -- and permits scripts named like built-ins', () => { diff --git a/common/changes/@rushstack/rush-cli-client/agent-reporter_2026-09-24-02-35.json b/common/changes/@rushstack/rush-cli-client/agent-reporter_2026-09-24-02-35.json index 2d8320a7cd..97bc13b49c 100644 --- a/common/changes/@rushstack/rush-cli-client/agent-reporter_2026-09-24-02-35.json +++ b/common/changes/@rushstack/rush-cli-client/agent-reporter_2026-09-24-02-35.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/rush-cli-client", - "comment": "Add an opt-in agent output mode on the daemon path (RUSHD_OUTPUT=agent, --reporter=ai, RUSH_REPORTER=ai, or COPILOT_CLI) with an immediate first line, bounded live progress, and a guaranteed final summary; keep repositories that opt into useRushReporter on the native reporter path.", + "comment": "Add an opt-in agent output mode on the daemon path (RUSHD_OUTPUT=agent or COPILOT_CLI) with an immediate first line, bounded live progress, and a guaranteed final summary; keep repositories that opt into useRushReporter on the native reporter path.", "type": "patch" } ], From 05767b405b1a438a6670e15d6c3962ee9db95d94 Mon Sep 17 00:00:00 2001 From: selarkin Date: Thu, 24 Sep 2026 11:09:24 -0700 Subject: [PATCH 3/3] [rush-cli-client] Address review: ABORTED counts, per-operation failure tails, final line on rejection, admission-control command name, normalized RUSH_REPORTER, queue dedup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/AgentProgressRenderer.ts | 107 ++++++++++-------- apps/rush-cli-client/src/launchClient.ts | 4 +- apps/rush-cli-client/src/outputSelection.ts | 40 ++++++- apps/rush-cli-client/src/routing.ts | 3 +- apps/rush-cli-client/src/start.ts | 17 +-- .../src/test/AgentProgressRenderer.test.ts | 50 +++++++- .../src/test/outputSelection.test.ts | 26 ++++- apps/rush-cli-client/src/test/routing.test.ts | 1 + 8 files changed, 186 insertions(+), 62 deletions(-) diff --git a/apps/rush-cli-client/src/AgentProgressRenderer.ts b/apps/rush-cli-client/src/AgentProgressRenderer.ts index 03b83a450f..08fa108796 100644 --- a/apps/rush-cli-client/src/AgentProgressRenderer.ts +++ b/apps/rush-cli-client/src/AgentProgressRenderer.ts @@ -14,7 +14,8 @@ const TERMINAL_STATUSES: ReadonlySet = new Set([ 'FROM CACHE', 'FAILURE', 'BLOCKED', - 'NO OP' + 'NO OP', + 'ABORTED' ]); const MAX_ERROR_LINES: number = 10; const PIPE_MIN_INTERVAL_MS: number = 2000; @@ -49,8 +50,7 @@ export class AgentProgressRenderer { readonly #running: Set = new Set(); readonly #counts: Map = new Map(); readonly #failed: string[] = []; - readonly #errorLines: string[] = []; - readonly #stderrOperations: Set = new Set(); + readonly #stderrTails: Map = new Map(); readonly #stdoutTails: Map = new Map(); #total: number = 0; #done: number = 0; @@ -61,6 +61,7 @@ export class AgentProgressRenderer { #lastLineKey: string = ''; #lastLineAtMs: number = -Infinity; #timer: ReturnType | undefined; + #stopped: boolean = false; public constructor(options: IAgentProgressRendererOptions) { this.#options = options; @@ -79,6 +80,9 @@ export class AgentProgressRenderer { } public setPhase(phase: string): void { + if (phase === this.#phase) { + return; + } this.#phase = phase; this.#render(true); } @@ -116,6 +120,7 @@ export class AgentProgressRenderer { this.#failed.push(operationId); } else { this.#stdoutTails.delete(operationId); + this.#stderrTails.delete(operationId); } } break; @@ -143,56 +148,42 @@ export class AgentProgressRenderer { } /** - * Keeps a bounded stderr tail, plus a small per-operation stdout tail that is only - * printed for failed operations whose diagnostics went to stdout (tsc, eslint, jest). + * Keeps bounded per-operation stderr and stdout tails (the last lines of each). They are only + * printed for failed operations; stdout is used when an operation reported its diagnostics + * there (tsc, eslint, jest) and wrote nothing to stderr. */ public onLog(bytes: Uint8Array, operationId: string, stream: 'stdout' | 'stderr'): void { + const status: string | undefined = this.#statuses.get(operationId); + if (status !== undefined && TERMINAL_STATUSES.has(status) && status !== 'FAILURE') { + return; + } + const tails: Map = stream === 'stderr' ? this.#stderrTails : this.#stdoutTails; for (const line of Buffer.from(bytes).toString('utf8').split('\n')) { if (!line.trim()) { continue; } - if (stream === 'stderr') { - this.#stderrOperations.add(operationId); - if (this.#errorLines.length < MAX_ERROR_LINES) { - this.#errorLines.push(`${operationId}: ${line.trim()}`); - } - } else { - const status: string | undefined = this.#statuses.get(operationId); - if (status !== undefined && status !== 'EXECUTING' && status !== 'FAILURE') { - continue; - } - let tail: string[] | undefined = this.#stdoutTails.get(operationId); - if (!tail) { - tail = []; - this.#stdoutTails.set(operationId, tail); - } - tail.push(line.trim()); - if (tail.length > MAX_ERROR_LINES) { - tail.shift(); - } + let tail: string[] | undefined = tails.get(operationId); + if (!tail) { + tail = []; + tails.set(operationId, tail); + } + tail.push(line.trim()); + if (tail.length > MAX_ERROR_LINES) { + tail.shift(); } } } /** Stops rendering without a summary (e.g. the request is handed to in-process Rush). */ - public dispose(finalNote?: string): void { - if (this.#timer) { - clearInterval(this.#timer); - this.#timer = undefined; - } - this.#clear(); - if (finalNote) { - this.#options.write(`rush ${this.#options.commandName}: ${finalNote}\n`); - } + public dispose(): void { + this.#stop(); } - /** Stops the live region and writes the final summary line. */ + /** Stops the live region and writes the final summary line, at most once. */ public finish(result: IAgentFinalResult | undefined): void { - if (this.#timer) { - clearInterval(this.#timer); - this.#timer = undefined; + if (!this.#stop()) { + return; } - this.#clear(); const succeeded: boolean = result !== undefined && result.exitCode === 0; const total: number = this.#getTotal(); const parts: string[] = [...this.#counts].map(([status, count]) => `${count} ${status.toLowerCase()}`); @@ -209,20 +200,39 @@ export class AgentProgressRenderer { } this.#options.write(`${line}\n`); if (!succeeded) { - const lines: string[] = [...this.#errorLines]; - for (const operationId of this.#failed) { - if (!this.#stderrOperations.has(operationId)) { - for (const stdoutLine of this.#stdoutTails.get(operationId) ?? []) { - lines.push(`${operationId}: ${stdoutLine}`); - } - } - } - for (const errorLine of lines.slice(0, MAX_ERROR_LINES)) { + for (const errorLine of this.#getFailureLines().slice(0, MAX_ERROR_LINES)) { this.#options.write(` ${errorLine}\n`); } } } + /** Failed operations' tails, or every operation's stderr tail when no operation failed. */ + #getFailureLines(): string[] { + const operationIds: Iterable = this.#failed.length ? this.#failed : this.#stderrTails.keys(); + const lines: string[] = []; + for (const operationId of operationIds) { + const tail: string[] = this.#stderrTails.get(operationId) ?? this.#stdoutTails.get(operationId) ?? []; + for (const line of tail) { + lines.push(`${operationId}: ${line}`); + } + } + return lines; + } + + /** Returns false if rendering had already stopped; after stopping, nothing more is written. */ + #stop(): boolean { + if (this.#stopped) { + return false; + } + this.#stopped = true; + if (this.#timer) { + clearInterval(this.#timer); + this.#timer = undefined; + } + this.#clear(); + return true; + } + #getTotal(): number { return Math.max(this.#total, this.#registered.size, this.#done); } @@ -245,6 +255,9 @@ export class AgentProgressRenderer { } #render(force: boolean): void { + if (this.#stopped) { + return; + } const rows: [string, string, string] = this.#rows(); if (this.#options.isTTY) { const width: number = Math.max(20, this.#options.columns || 80) - 1; diff --git a/apps/rush-cli-client/src/launchClient.ts b/apps/rush-cli-client/src/launchClient.ts index 58c7d251db..ed63461f8b 100644 --- a/apps/rush-cli-client/src/launchClient.ts +++ b/apps/rush-cli-client/src/launchClient.ts @@ -214,10 +214,10 @@ export async function launchClientAsync( ); } } else if (outcome.kind === 'rejected') { - agentRenderer?.dispose(); + agentRenderer?.finish({ exitCode: 1, errorMessage: `daemon rejected the request (${outcome.rejection.code})` }); throw new Error(`Daemon rejected the request (${outcome.rejection.code}): ${outcome.rejection.message}`); } else if (abort.signal.aborted) { - agentRenderer?.dispose('cancelled'); + agentRenderer?.finish({ exitCode: 130, errorMessage: 'cancelled' }); process.exitCode = 130; } else { agentRenderer?.dispose(); diff --git a/apps/rush-cli-client/src/outputSelection.ts b/apps/rush-cli-client/src/outputSelection.ts index ed5c1e6a37..cc5bce489c 100644 --- a/apps/rush-cli-client/src/outputSelection.ts +++ b/apps/rush-cli-client/src/outputSelection.ts @@ -23,6 +23,44 @@ function isActive(value: string | undefined): boolean { return value !== undefined && !INACTIVE_VALUES.has(value.trim().toLowerCase()); } +/** + * Returns true when `RUSH_REPORTER` requests a native reporter, i.e. it is set to anything other than + * the `legacy` escape hatch. Normalized like `isLegacyEmergencyFallbackRequested()` in `@rushstack/reporter`. + */ +export function isNativeReporterEnvironmentRequested(value: string | undefined): boolean { + return value !== undefined && value.trim().toLowerCase() !== 'legacy'; +} + +/** + * Returns the command name for early agent output, or undefined when the invocation has no plain + * command (a leading option, `--help`/`-h`, or `daemon`). Daemon admission controls (`--no-wait`, + * `--wait-timeout SECONDS`) are skipped like `parseClientAdmissionControls()`, which is not imported + * here to avoid loading `@rushstack/rush-daemon-protocol` before the first line; invalid controls + * are reported later by the full parser. + */ +export function getAgentCommandName(argv: ReadonlyArray): string | undefined { + const remaining: string[] = []; + for (let index: number = 0; index < argv.length && argv[index] !== '--'; index++) { + const arg: string = argv[index]; + if (arg === '--wait-timeout') { + index++; + } else if (arg !== '--no-wait' && !arg.startsWith('--wait-timeout=')) { + remaining.push(arg); + } + } + const commandName: string | undefined = remaining[0]; + if ( + commandName === undefined || + commandName.startsWith('-') || + commandName === 'daemon' || + remaining.includes('--help') || + remaining.includes('-h') + ) { + return undefined; + } + return commandName; +} + /** * Returns true when the invocation explicitly selects a reporter, output or log level * (`--reporter`, `--output`, `--log-level` before `--`, `RUSH_REPORTER` other than `legacy`, @@ -35,7 +73,7 @@ export function hasExplicitReporterControls( if (environment.RUSH_LOG_LEVEL !== undefined) { return true; } - if (environment.RUSH_REPORTER !== undefined && environment.RUSH_REPORTER !== 'legacy') { + if (isNativeReporterEnvironmentRequested(environment.RUSH_REPORTER)) { return true; } const separator: number = argv.indexOf('--'); diff --git a/apps/rush-cli-client/src/routing.ts b/apps/rush-cli-client/src/routing.ts index c8a1c53b3f..4cedb80a18 100644 --- a/apps/rush-cli-client/src/routing.ts +++ b/apps/rush-cli-client/src/routing.ts @@ -5,6 +5,7 @@ import type { IDaemonRequestAdmissionOptions } from '@rushstack/rush-daemon-prot import { RushXCommand, type IRushXCommandLineArguments } from '@microsoft/rush-lib'; import { parseClientAdmissionControls, type IClientAdmissionControls } from './ClientAdmissionControls'; +import { isNativeReporterEnvironmentRequested } from './outputSelection'; const neverDaemonize: ReadonlySet = new Set([ 'add', @@ -60,7 +61,7 @@ export function selectClientRoute(options: IClientRouteOptions): IClientRoute { const commandName: string | undefined = rushxArguments ? rushxArguments.commandName || undefined : argv[0]; const reporterControls: boolean = options.environment.RUSH_LOG_LEVEL !== undefined || - (options.environment.RUSH_REPORTER !== undefined && options.environment.RUSH_REPORTER !== 'legacy') || + isNativeReporterEnvironmentRequested(options.environment.RUSH_REPORTER) || (!options.rushx && prefix.some((arg) => ['--reporter', '--output', '--log-level'].some((name) => arg === name || arg.startsWith(`${name}=`)) diff --git a/apps/rush-cli-client/src/start.ts b/apps/rush-cli-client/src/start.ts index 56ed84028c..989adb4e9f 100644 --- a/apps/rush-cli-client/src/start.ts +++ b/apps/rush-cli-client/src/start.ts @@ -2,11 +2,16 @@ // See LICENSE in the project root for license information. import { AgentProgressRenderer } from './AgentProgressRenderer'; -import { findRushJsonPath, readUseRushReporter, selectClientOutputMode } from './outputSelection'; +import { + findRushJsonPath, + getAgentCommandName, + readUseRushReporter, + selectClientOutputMode +} from './outputSelection'; const startTimeMs: number = Date.now(); const argv: string[] = process.argv.slice(2); -const commandName: string | undefined = argv[0]; +const commandName: string | undefined = getAgentCommandName(argv); const rushJsonPath: string | undefined = findRushJsonPath(process.cwd()); // Write the agent status line before loading @microsoft/rush-lib (hundreds of milliseconds). const agentRenderer: AgentProgressRenderer | undefined = @@ -15,11 +20,7 @@ const agentRenderer: AgentProgressRenderer | undefined = environment: process.env, useRushReporter: !!rushJsonPath && readUseRushReporter(rushJsonPath) }) === 'agent' && - commandName !== undefined && - !commandName.startsWith('-') && - commandName !== 'daemon' && - !process.argv.includes('--help') && - !process.argv.includes('-h') + commandName !== undefined ? new AgentProgressRenderer({ commandName, isTTY: !!process.stdout.isTTY && process.env.TERM !== 'dumb', @@ -33,7 +34,7 @@ agentRenderer?.start(); const { launchClientAsync } = require('./launchClient') as typeof import('./launchClient'); launchClientAsync(false, agentRenderer).catch((error: Error) => { - agentRenderer?.dispose(); + agentRenderer?.finish({ exitCode: 1, errorMessage: error.message }); process.stderr.write(`rush-client: ${error.message}\n`); process.exitCode = 1; }); diff --git a/apps/rush-cli-client/src/test/AgentProgressRenderer.test.ts b/apps/rush-cli-client/src/test/AgentProgressRenderer.test.ts index a0449bc1e2..6a23fc2c5c 100644 --- a/apps/rush-cli-client/src/test/AgentProgressRenderer.test.ts +++ b/apps/rush-cli-client/src/test/AgentProgressRenderer.test.ts @@ -84,8 +84,54 @@ describe(AgentProgressRenderer.name, () => { renderer.finish({ exitCode: 1 }); const text: string = output.join(''); expect(text).toContain('rush build: FAILURE 2/2 operations (1 failure, 1 blocked) in 0.0s · failed: p05 (build)\n'); - expect(text).toContain(' p05 (build): error 0\n'); - expect(text).not.toContain('error 10'); + expect(text).toContain(' p05 (build): error 10\n'); + expect(text).toContain(' p05 (build): error 19\n'); + expect(text).not.toContain('error 9\n'); + }); + + it('keeps failure diagnostics when successful operations wrote stderr first', () => { + const { renderer, output } = createRenderer(false); + renderer.onEvent(status('noisy (build)', 'EXECUTING')); + for (let i = 0; i < 20; i++) { + renderer.onLog(Buffer.from(`warning ${i}\n`), 'noisy (build)', 'stderr'); + } + renderer.onEvent(status('noisy (build)', 'SUCCESS WITH WARNINGS')); + renderer.onEvent(status('broken (build)', 'EXECUTING')); + renderer.onLog(Buffer.from('the real error\n'), 'broken (build)', 'stderr'); + renderer.onEvent(status('broken (build)', 'FAILURE')); + renderer.finish({ exitCode: 1 }); + const text: string = output.join(''); + expect(text).toContain(' broken (build): the real error\n'); + expect(text).not.toContain('noisy (build): warning'); + }); + + it('counts ABORTED operations as finished', () => { + const { renderer, output } = createRenderer(false); + renderer.onEvent(event('operationRegistered', { operationId: 'a (build)', silent: false })); + renderer.onEvent(status('a (build)', 'EXECUTING')); + renderer.onEvent(status('a (build)', 'ABORTED')); + renderer.finish({ exitCode: 1 }); + expect(output[output.length - 1]).toBe('rush build: FAILURE 1/1 operations (1 aborted) in 0.0s\n'); + }); + + it('writes the final line at most once and nothing after it', () => { + const { renderer, output } = createRenderer(false); + renderer.finish({ exitCode: 1, errorMessage: 'daemon rejected the request (x)' }); + renderer.finish({ exitCode: 1, errorMessage: 'again' }); + renderer.onQueuePosition(3); + renderer.dispose(); + expect(output).toEqual(['rush build: FAILURE 0/0 operations in 0.0s · daemon rejected the request (x)\n']); + }); + + it('does not repeat an unchanged queue position', () => { + const { renderer, output } = createRenderer(false); + renderer.onQueuePosition(2); + renderer.onQueuePosition(2); + renderer.onQueuePosition(2); + expect(output).toHaveLength(1); + renderer.onQueuePosition(1); + expect(output).toHaveLength(2); + renderer.dispose(); }); it('shows the stdout tail of a failed operation that reported errors on stdout', () => { diff --git a/apps/rush-cli-client/src/test/outputSelection.test.ts b/apps/rush-cli-client/src/test/outputSelection.test.ts index e1bd469af6..23f11f467d 100644 --- a/apps/rush-cli-client/src/test/outputSelection.test.ts +++ b/apps/rush-cli-client/src/test/outputSelection.test.ts @@ -5,7 +5,12 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { findRushJsonPath, readUseRushReporter, selectClientOutputMode } from '../outputSelection'; +import { + findRushJsonPath, + getAgentCommandName, + readUseRushReporter, + selectClientOutputMode +} from '../outputSelection'; describe(selectClientOutputMode.name, () => { it.each([ @@ -17,6 +22,7 @@ describe(selectClientOutputMode.name, () => { { argv: ['build'], environment: { COPILOT_CLI: '1', RUSHD_OUTPUT: 'legacy' }, mode: 'legacy' }, { argv: ['build'], environment: { CLAUDECODE: '1' }, mode: 'legacy' }, { argv: ['build'], environment: { COPILOT_CLI: '1', RUSH_REPORTER: 'legacy' }, mode: 'agent' }, + { argv: ['build'], environment: { COPILOT_CLI: '1', RUSH_REPORTER: ' LEGACY ' }, mode: 'agent' }, // Explicit reporter controls always keep the native reporter path, and nothing is written ahead of it. { argv: ['build', '--reporter=ai'], environment: { RUSHD_OUTPUT: 'agent' }, mode: 'legacy' }, { argv: ['build', '--reporter', 'ai'], environment: { COPILOT_CLI: '1' }, mode: 'legacy' }, @@ -37,6 +43,24 @@ describe(selectClientOutputMode.name, () => { }); }); +describe(getAgentCommandName.name, () => { + it.each([ + { argv: ['build'], commandName: 'build' }, + { argv: ['--wait-timeout', '1.25', 'build'], commandName: 'build' }, + { argv: ['--wait-timeout=2', 'build', '-t', 'a'], commandName: 'build' }, + { argv: ['--no-wait', 'rebuild'], commandName: 'rebuild' }, + { argv: ['--', 'build'], commandName: undefined }, + { argv: ['-q', 'build'], commandName: undefined }, + { argv: ['daemon', 'status'], commandName: undefined }, + { argv: ['build', '--help'], commandName: undefined }, + { argv: ['build', '-h'], commandName: undefined }, + { argv: ['build', '--', '--help'], commandName: 'build' }, + { argv: [], commandName: undefined } + ])('returns $commandName for $argv', ({ argv, commandName }) => { + expect(getAgentCommandName(argv)).toBe(commandName); + }); +}); + describe(readUseRushReporter.name, () => { let folder: string; beforeEach(() => { diff --git a/apps/rush-cli-client/src/test/routing.test.ts b/apps/rush-cli-client/src/test/routing.test.ts index 4b41765cdd..0bac92c254 100644 --- a/apps/rush-cli-client/src/test/routing.test.ts +++ b/apps/rush-cli-client/src/test/routing.test.ts @@ -16,6 +16,7 @@ describe('opt-in routing', () => { { argv: ['build', '--log-level=debug'], enabled: true, environment: {}, daemon: false }, { argv: ['build'], enabled: true, environment: { RUSH_REPORTER: 'json' }, daemon: false }, { argv: ['build'], enabled: true, environment: { RUSH_REPORTER: 'legacy' }, daemon: true }, + { argv: ['build'], enabled: true, environment: { RUSH_REPORTER: ' LEGACY ' }, daemon: true }, { argv: ['install'], enabled: true, environment: { RUSH_DAEMON: '1' }, daemon: true }, { argv: ['update'], enabled: true, environment: { RUSH_DAEMON: '1' }, daemon: true }, { argv: ['install', '--no-daemon'], enabled: true, environment: { RUSH_DAEMON: '1' }, daemon: false },