feat(errors): name a deadline's parameter or environment control in details.limit (#11172)

This commit is contained in:
morluto authored and GitHub committed 2026-11-15 21:49:35 +08:00
1 parent 2d4a97b405
commit 863d82e370
16 files changed
+159 -50

No files matched your search

+11
View File
@@ -291,6 +291,17 @@ Error copying, attribution, redaction, and delivery must preserve these facts.
Human-readable remediation explains the same failure; clients should not need
to parse that sentence to identify an editable setting.
A deadline controlled by something other than a registered setting reports
`details.limit` with `control_kind` instead of `setting`.
`control_kind: "parameter"` names the per-call `parameter` and its `cli_flag`
(`native_command_timeout_ms`, `native_ui_deadline_ms`,
`pwntools_timeout_ms`) and applies on the next call.
`control_kind: "environment"` names the `environment` variable REA reads when
it starts (`REA_SWIFTC_COMPILE_TIMEOUT_MS`): `applies_at` is `next_startup`,
or `next_invocation` for a one-shot invocation. Both carry `effective_value`,
`unit`, `minimum`, `maximum` and `default_value`. `update_analysis_settings`
changes neither. A limit without `control_kind` is a registered setting.
This output is breaking. Results and errors no longer carry the previous
restart boolean or a single effect field, and there is no compatibility alias.
Read `update_mode` to see whether the setter can change the active value, and
+4 -4
View File
@@ -5,7 +5,7 @@ import type { PlaywrightTrace } from "./playwrightTrace/playwrightTrace.js";
import type { ApiContract } from "./apiContract/apiContract.js";
import type { GraphqlContract } from "./apiContract/graphqlContract.js";
import type { SavedAnalysisSnapshotPartialObservation } from "./savedAnalysisSnapshot.js";
import type { AnalysisSettingLimit } from "./analysisSettings.js";
import type { AnalysisLimit } from "./analysisSettings.js";
import type { JsonValue } from "./jsonValue.js";
import type { AnalysisCleanupRetry } from "./analysisErrorRecovery.js";
import type { EnvironmentBundlePublication } from "./environmentBundleExport.js";
@@ -100,7 +100,7 @@ export interface AnalysisErrorOptions extends ErrorOptions {
readonly cleanupPrimary?: AnalysisError | undefined;
/** Producer-owned, caller-safe facts retained through every typed projection. */
readonly diagnostics?: Readonly<Record<string, JsonValue>> | undefined;
readonly limit?: AnalysisSettingLimit | undefined;
readonly limit?: AnalysisLimit | undefined;
readonly continuingCallerGuidance?:
| AnalysisContinuingCallerGuidance
| undefined;
@@ -165,9 +165,9 @@ export interface SourcePublicationRecovery {
/** Base class for expected analysis, provider, and session failures. */
export abstract class AnalysisError extends Error {
limit: Readonly<AnalysisSettingLimit> | undefined;
limit: Readonly<AnalysisLimit> | undefined;
/** Bind the accepted setting at its producing boundary. */
bindLimit(limit: AnalysisSettingLimit): this {
bindLimit(limit: AnalysisLimit): this {
this.limit = Object.freeze({ ...limit });
return this;
}
+2 -2
View File
@@ -59,7 +59,7 @@ import {
type AnalysisErrorOptions,
type AnalysisErrorTag,
} from "./analysisErrorBase.js";
import { invocationSettingLimit } from "./analysisSettingDefinitions.js";
import { invocationLimit } from "./analysisSettingDefinitions.js";
import { analysisErrorRefusal } from "./analysisErrorRefusal.js";
import { analysisRefusalDetails } from "./analysisRefusal.js";
@@ -428,7 +428,7 @@ const projectError = (
: {
limit:
callerLifetime === "invocation"
? { ...invocationSettingLimit(primary.limit) }
? { ...invocationLimit(primary.limit) }
: { ...primary.limit },
}),
...(refusal === undefined
+1
View File
@@ -22,6 +22,7 @@ export const analysisErrorRefusal = (
};
if (
error.limit !== undefined &&
!("control_kind" in error.limit) &&
!(
"measuredExhaustion" in error.recovery &&
error.recovery.measuredExhaustion === true
+13 -1
View File
@@ -1,6 +1,8 @@
import { z } from "zod";
import type {
AnalysisControlLimit,
AnalysisInvocationSettingLimit,
AnalysisLimit,
AnalysisSettingId,
AnalysisSettingLimit,
} from "./analysisSettings.js";
@@ -650,7 +652,17 @@ export const analysisSettingLimit = (
};
/** Re-scope an enforced limit for a caller whose runtime ended with this invocation. */
export const invocationSettingLimit = ({
export const invocationLimit = (
limit: AnalysisLimit,
): AnalysisInvocationSettingLimit | AnalysisControlLimit => {
if (!("control_kind" in limit)) return invocationSettingLimit(limit);
// A new invocation reads its environment without restarting a running REA.
return limit.control_kind === "environment"
? { ...limit, applies_at: "next_invocation" }
: { ...limit };
};
const invocationSettingLimit = ({
applies_at: _appliesAt,
scope: _scope,
...facts
+32
View File
@@ -83,3 +83,35 @@ export interface AnalysisInvocationSettingLimit extends Omit<
/** Launch-environment seed that a new invocation reads, when one exists. */
readonly environment?: string;
}
/**
* A limit whose control is not a registered analysis setting: a per-call
* request parameter, or an environment variable REA reads when it starts. A
* limit without `control_kind` is an `AnalysisSettingLimit` named by
* `setting`. `update_analysis_settings` changes neither control.
*/
export type AnalysisControlLimit = {
readonly effective_value: number;
readonly unit: AnalysisSettingLimit["unit"];
readonly minimum: number;
readonly maximum: number;
/** Value used when the control is omitted. */
readonly default_value: number;
} & (
| {
readonly control_kind: "parameter";
readonly parameter: string;
/** CLI spelling of the same per-call parameter. */
readonly cli_flag: string;
readonly applies_at: "next_call";
}
| {
readonly control_kind: "environment";
readonly environment: string;
/** A one-invocation caller's next invocation reads it without a restart. */
readonly applies_at: "next_startup" | "next_invocation";
}
);
/** The limit an error binds: a registered setting, or a parameter or environment control. */
export type AnalysisLimit = AnalysisSettingLimit | AnalysisControlLimit;
+22 -6
View File
@@ -3,6 +3,7 @@ import {
timerDurationMsSchema,
} from "../analysisResourceLimits.js";
import type { AnalysisControlLimit } from "../analysisSettings.js";
import {
fixedAllowanceRemediation,
type FixedAllowanceKind,
@@ -70,23 +71,38 @@ export const readNativeDeadline = (
};
};
/** Agent recovery for a caller-selected native deadline at today's default. */
/** Agent recovery and limit facts for a caller-selected native deadline. */
export const nativeDeadlineRecovery = (input: {
readonly timeoutMs: number;
readonly setting: string;
readonly parameter: string;
readonly cliFlag: string;
readonly defaultMs: number;
readonly phase: string;
readonly unchanged: string;
readonly detail?: string;
}): { readonly userMessage: string; readonly remediationAction: string } => {
}): {
readonly userMessage: string;
readonly remediationAction: string;
readonly limit: AnalysisControlLimit;
} => {
const atMaximum = input.timeoutMs >= MAX_NODE_TIMER_DELAY_MS;
const adjustment = atMaximum
? `${input.setting} (${input.cliFlag} on the CLI) is already at the maximum ${String(MAX_NODE_TIMER_DELAY_MS)} ms; do not increase it.`
: `Increase ${input.setting} above ${String(input.timeoutMs)} (integer milliseconds from 1 to ${String(MAX_NODE_TIMER_DELAY_MS)}; omitted means ${String(input.defaultMs)}), or pass ${input.cliFlag} on the CLI. A change applies on the next call.`;
? `${input.parameter} (${input.cliFlag} on the CLI) is already at the maximum ${String(MAX_NODE_TIMER_DELAY_MS)} ms; do not increase it.`
: `Increase ${input.parameter} above ${String(input.timeoutMs)} (integer milliseconds from 1 to ${String(MAX_NODE_TIMER_DELAY_MS)}; omitted means ${String(input.defaultMs)}), or pass ${input.cliFlag} on the CLI. A change applies on the next call.`;
return {
userMessage: `This request reached REA's ${input.phase} of ${String(input.timeoutMs)} ms (${input.setting}). This is a server/provider deadline, not the MCP client deadline.${input.detail ?? ""}`,
userMessage: `This request reached REA's ${input.phase} of ${String(input.timeoutMs)} ms (${input.parameter}). This is a server/provider deadline, not the MCP client deadline.${input.detail ?? ""}`,
remediationAction: `${adjustment} ${input.unchanged} Allow execution, cleanup, and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`,
limit: {
control_kind: "parameter",
parameter: input.parameter,
cli_flag: input.cliFlag,
effective_value: input.timeoutMs,
unit: "ms",
minimum: 1,
maximum: MAX_NODE_TIMER_DELAY_MS,
default_value: input.defaultMs,
applies_at: "next_call",
},
};
};
+28 -17
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js";
import { MAX_NODE_TIMER_DELAY_MS } from "../domain/analysisResourceLimits.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import {
@@ -68,12 +69,22 @@ describe("translateNativeCommandFailure", () => {
retryable: true,
});
expect(projected.message).toContain("1 ms");
expect(projected.remediation.action).toContain(
"REA_SWIFTC_COMPILE_TIMEOUT_MS",
);
expect(projected.remediation.action).not.toContain(
"native_command_timeout_ms",
);
const limit = {
control_kind: "environment",
environment: "REA_SWIFTC_COMPILE_TIMEOUT_MS",
effective_value: 1,
unit: "ms",
minimum: 1,
maximum: MAX_NODE_TIMER_DELAY_MS,
default_value: 120_000,
applies_at: "next_startup",
};
expect(projected.details?.limit).toEqual(limit);
// A one-invocation caller's next invocation reads it without a restart.
expect(projectAnalysisError(error, "invocation").details?.limit).toEqual({
...limit,
applies_at: "next_invocation",
});
});
it("does not infer a compiler timeout duration when it was not reported", () => {
@@ -104,22 +115,22 @@ describe("translateNativeCommandFailure", () => {
}),
new ArtifactResourceScope(),
60_000,
{
timeoutRecovery: {
userMessage: "native command deadline",
remediationAction: "native_command_timeout_ms",
},
},
);
expect(error).toBeInstanceOf(AnalysisTimeoutError);
if (!(error instanceof AnalysisTimeoutError)) return;
expect(error.timeoutMs).toBe(60_000);
const projected = projectAnalysisError(error);
expect(projected.remediation.action).toContain("native_command_timeout_ms");
expect(projected.remediation.action).not.toContain(
"REA_SWIFTC_COMPILE_TIMEOUT_MS",
);
expect(projectAnalysisError(error).details?.limit).toEqual({
control_kind: "parameter",
parameter: "native_command_timeout_ms",
cli_flag: "--native-command-timeout-ms",
effective_value: 60_000,
unit: "ms",
minimum: 1,
maximum: MAX_NODE_TIMER_DELAY_MS,
default_value: 60_000,
applies_at: "next_call",
});
});
it("keeps compiler exit failures distinct from deadlines", () => {
+5 -6
View File
@@ -13,6 +13,7 @@ import {
AnalysisCapabilityUnavailableError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
type AnalysisTimeoutRecovery,
cancelledBy,
carriedReason,
} from "../domain/analysisErrorCore.js";
@@ -32,10 +33,7 @@ import {
/** Operation-owned text for a command deadline or unavailable requirement. */
export interface NativeCommandFailurePresentation {
readonly unavailableMessage?: string;
readonly timeoutRecovery?: {
readonly userMessage: string;
readonly remediationAction: string;
};
readonly timeoutRecovery?: AnalysisTimeoutRecovery;
}
/** Translate the command owner's facts once, preserving capture and cleanup authority. */
@@ -116,7 +114,7 @@ export const translateNativeCommandFailure = (
...(presentation.timeoutRecovery ??
nativeDeadlineRecovery({
timeoutMs,
setting: "native_command_timeout_ms",
parameter: "native_command_timeout_ms",
cliFlag: "--native-command-timeout-ms",
defaultMs: DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
phase: "macOS native command deadline",
@@ -135,6 +133,7 @@ export const translateNativeCommandFailure = (
...options,
userMessage: darwin.compileTimeout.userMessage,
remediationAction: darwin.compileTimeout.remediationAction,
limit: darwin.compileTimeout.limit,
},
)
: new ProviderAdapterError(IDENTITY.id, operation, options);
@@ -201,7 +200,7 @@ export const translateNativeCommandFailure = (
...(presentation.timeoutRecovery ??
nativeDeadlineRecovery({
timeoutMs,
setting: "native_command_timeout_ms",
parameter: "native_command_timeout_ms",
cliFlag: "--native-command-timeout-ms",
defaultMs: DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
phase: "macOS native command deadline",
+1 -1
View File
@@ -91,7 +91,7 @@ export class NativeUiInterruptionControl {
partialObservation,
...nativeDeadlineRecovery({
timeoutMs: this.#deadlineMs,
setting: "native_ui_deadline_ms",
parameter: "native_ui_deadline_ms",
cliFlag: "--native-ui-deadline-ms",
defaultMs: DEFAULT_NATIVE_UI_DEADLINE_MS,
phase: "native UI observation deadline",
+6 -2
View File
@@ -533,8 +533,12 @@ it("classifies a helper subprocess timeout as the selected UI deadline", async (
const projected = projectAnalysisError(result.error);
expect(projected.code).toBe("provider_timeout");
expect(projected.message).toContain("of 2500 ms");
expect(projected.message).toContain("native_ui_deadline_ms");
expect(projected.remediation.action).toContain("above 2500");
expect(projected.details?.limit).toMatchObject({
control_kind: "parameter",
parameter: "native_ui_deadline_ms",
effective_value: 2_500,
default_value: 180_000,
});
expect(projected.message).not.toContain("of 30000 ms");
expect(projected.message).not.toContain("of 60000 ms");
});
+7 -2
View File
@@ -118,8 +118,13 @@ it("distinguishes the wall deadline from caller-selected producer resource budge
);
expect(projected.code).toBe("provider_timeout");
expect(projected.message).toContain("4500 ms");
expect(projected.message).toContain("pwntools_timeout_ms");
expect(projected.remediation.action).toContain("--pwntools-timeout-ms");
expect(projected.details?.limit).toMatchObject({
control_kind: "parameter",
parameter: "pwntools_timeout_ms",
cli_flag: "--pwntools-timeout-ms",
effective_value: 4_500,
default_value: 30_000,
});
expect(projected.remediation.action).toContain("cpu_seconds=30");
expect(projected.remediation.action).toContain("producer_limits");
expect(projected.remediation.action).toContain("no REA restart");
+2 -1
View File
@@ -225,7 +225,7 @@ const pwntoolsCommandContext = (
const timeoutMs = context.timeoutMs ?? PWNTOOLS_LIMITS.command_timeout_ms;
const deadline = nativeDeadlineRecovery({
timeoutMs,
setting: "pwntools_timeout_ms",
parameter: "pwntools_timeout_ms",
cliFlag: "--pwntools-timeout-ms",
defaultMs: PWNTOOLS_LIMITS.command_timeout_ms,
phase: "pwntools command deadline",
@@ -254,6 +254,7 @@ const pwntoolsCommandContext = (
ms: timeoutMs,
userMessage: deadline.userMessage,
remediationAction: deadline.remediationAction,
limit: deadline.limit,
},
outputLimit: {
limits: {
@@ -19,6 +19,7 @@ import {
providerStartupTimeoutSchema,
} from "../config/providerStartupTimeout.js";
import { MAX_NODE_TIMER_DELAY_MS } from "../domain/analysisResourceLimits.js";
import type { AnalysisControlLimit } from "../domain/analysisSettings.js";
import { err, ok, type Result } from "../domain/result.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { AnalysisCleanupRelease } from "../domain/analysisCleanupRelease.js";
@@ -197,6 +198,7 @@ export class DarwinProcessOwnershipInspectionError extends Error {
readonly timeoutMs: number;
readonly userMessage: string;
readonly remediationAction: string;
readonly limit: AnalysisControlLimit;
}
| undefined {
const outcome = this.compilerOutcome;
@@ -206,6 +208,16 @@ export class DarwinProcessOwnershipInspectionError extends Error {
timeoutMs: outcome.timeoutMs,
userMessage: `The macOS process ownership helper compiler reached its REA_SWIFTC_COMPILE_TIMEOUT_MS deadline of ${String(outcome.timeoutMs)} ms.`,
remediationAction: swiftcCompileTimeoutRecovery(outcome.timeoutMs),
limit: {
control_kind: "environment",
environment: "REA_SWIFTC_COMPILE_TIMEOUT_MS",
effective_value: outcome.timeoutMs,
unit: "ms",
minimum: 1,
maximum: MAX_NODE_TIMER_DELAY_MS,
default_value: DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS,
applies_at: "next_startup",
},
};
}
+3 -3
View File
@@ -11,7 +11,7 @@ import {
type ResourceConstraintRecovery,
} from "../domain/analysisErrorCore.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { AnalysisSettingLimit } from "../domain/analysisSettings.js";
import type { AnalysisLimit } from "../domain/analysisSettings.js";
import type { ProviderOutcome } from "./ProviderOutcome.js";
import type { ProviderProcessSnapshot } from "./ProviderProcess.js";
import {
@@ -55,8 +55,8 @@ export interface ProviderOutcomeContext {
readonly userMessage?: string;
/** The setting or action that changes that deadline. */
readonly remediationAction?: string;
/** The analysis setting that selected the deadline, when one did. */
readonly limit?: AnalysisSettingLimit;
/** The setting, parameter or environment control that selected the deadline. */
readonly limit?: AnalysisLimit;
};
readonly outputLimit: {
/** The budget that was exceeded, when the generic report is not enough. */
@@ -277,11 +277,16 @@ it.skipIf(process.platform === "win32")(
expect(failure.message).toMatch(
/ownership helper.*xcrun.*timed out after 3000 ms/iu,
);
expect(failure.message).toContain("REA_SWIFTC_COMPILE_TIMEOUT_MS");
expect(failure.message).toContain("1..2147483647");
expect(failure.message).toContain("120000 ms");
expect(failure.message).toMatch(/restart REA/i);
expect(failure.message).toMatch(/next ownership-helper compile/i);
expect(failure.compileTimeout?.limit).toEqual({
control_kind: "environment",
environment: "REA_SWIFTC_COMPILE_TIMEOUT_MS",
effective_value: 3_000,
unit: "ms",
minimum: 1,
maximum: 2_147_483_647,
default_value: 120_000,
applies_at: "next_startup",
});
expect(failure.message).toContain(
"error: PCH was compiled with module cache path that differs",
);