From 863d82e370f3d3b4e78e1c8d3543b26f371c71fe Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 21:49:35 +0800 Subject: [PATCH] feat(errors): name a deadline's parameter or environment control in details.limit (#11172) --- docs/design/limits-and-recovery.md | 11 +++++ src/domain/analysisErrorBase.ts | 8 ++-- src/domain/analysisErrorProjection.ts | 4 +- src/domain/analysisErrorRefusal.ts | 1 + src/domain/analysisSettingDefinitions.ts | 14 +++++- src/domain/analysisSettings.ts | 32 +++++++++++++ src/domain/native/nativeDeadlines.ts | 28 +++++++++--- src/native/NativeCommandAnalysisError.test.ts | 45 ++++++++++++------- src/native/NativeCommandAnalysisError.ts | 11 +++-- src/native/NativeUiInterruptionControl.ts | 2 +- src/native/NativeUiObservation.test.ts | 8 +++- src/native/pwntools/PwntoolsFailures.test.ts | 9 +++- src/native/pwntools/PwntoolsFailures.ts | 3 +- src/process/DarwinProcessRunTokenReader.ts | 12 +++++ src/process/classifyProviderOutcome.ts | 6 +-- .../darwinProcessRunTokenReader.test.ts | 15 ++++--- 16 files changed, 159 insertions(+), 50 deletions(-) diff --git a/docs/design/limits-and-recovery.md b/docs/design/limits-and-recovery.md index 60ae8df27c..638590b1ba 100644 --- a/docs/design/limits-and-recovery.md +++ b/docs/design/limits-and-recovery.md @@ -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 diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index ea57dac26f..39052a096e 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -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> | 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 | undefined; + limit: Readonly | undefined; /** Bind the accepted setting at its producing boundary. */ - bindLimit(limit: AnalysisSettingLimit): this { + bindLimit(limit: AnalysisLimit): this { this.limit = Object.freeze({ ...limit }); return this; } diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 8159b3ded8..3fc84796ff 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -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 diff --git a/src/domain/analysisErrorRefusal.ts b/src/domain/analysisErrorRefusal.ts index befcd627b4..2b89bc7d1b 100644 --- a/src/domain/analysisErrorRefusal.ts +++ b/src/domain/analysisErrorRefusal.ts @@ -22,6 +22,7 @@ export const analysisErrorRefusal = ( }; if ( error.limit !== undefined && + !("control_kind" in error.limit) && !( "measuredExhaustion" in error.recovery && error.recovery.measuredExhaustion === true diff --git a/src/domain/analysisSettingDefinitions.ts b/src/domain/analysisSettingDefinitions.ts index b86dde49b1..fa5a6546c3 100644 --- a/src/domain/analysisSettingDefinitions.ts +++ b/src/domain/analysisSettingDefinitions.ts @@ -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 diff --git a/src/domain/analysisSettings.ts b/src/domain/analysisSettings.ts index c6d70130c5..a1a4a95331 100644 --- a/src/domain/analysisSettings.ts +++ b/src/domain/analysisSettings.ts @@ -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; diff --git a/src/domain/native/nativeDeadlines.ts b/src/domain/native/nativeDeadlines.ts index f3402dadf8..c4f83b1739 100644 --- a/src/domain/native/nativeDeadlines.ts +++ b/src/domain/native/nativeDeadlines.ts @@ -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", + }, }; }; diff --git a/src/native/NativeCommandAnalysisError.test.ts b/src/native/NativeCommandAnalysisError.test.ts index 3c1d06b7a9..c3f1b59a1d 100644 --- a/src/native/NativeCommandAnalysisError.test.ts +++ b/src/native/NativeCommandAnalysisError.test.ts @@ -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", () => { diff --git a/src/native/NativeCommandAnalysisError.ts b/src/native/NativeCommandAnalysisError.ts index 76ba696993..4f8eb7676c 100644 --- a/src/native/NativeCommandAnalysisError.ts +++ b/src/native/NativeCommandAnalysisError.ts @@ -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", diff --git a/src/native/NativeUiInterruptionControl.ts b/src/native/NativeUiInterruptionControl.ts index ade12fafc9..4a7d386b32 100644 --- a/src/native/NativeUiInterruptionControl.ts +++ b/src/native/NativeUiInterruptionControl.ts @@ -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", diff --git a/src/native/NativeUiObservation.test.ts b/src/native/NativeUiObservation.test.ts index 847f4f48f1..023fe0ea11 100644 --- a/src/native/NativeUiObservation.test.ts +++ b/src/native/NativeUiObservation.test.ts @@ -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"); }); diff --git a/src/native/pwntools/PwntoolsFailures.test.ts b/src/native/pwntools/PwntoolsFailures.test.ts index 949cabfa79..e34331bc25 100644 --- a/src/native/pwntools/PwntoolsFailures.test.ts +++ b/src/native/pwntools/PwntoolsFailures.test.ts @@ -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"); diff --git a/src/native/pwntools/PwntoolsFailures.ts b/src/native/pwntools/PwntoolsFailures.ts index 9058a54e85..d0ae4b764a 100644 --- a/src/native/pwntools/PwntoolsFailures.ts +++ b/src/native/pwntools/PwntoolsFailures.ts @@ -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: { diff --git a/src/process/DarwinProcessRunTokenReader.ts b/src/process/DarwinProcessRunTokenReader.ts index 4299391656..880af6de8c 100644 --- a/src/process/DarwinProcessRunTokenReader.ts +++ b/src/process/DarwinProcessRunTokenReader.ts @@ -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", + }, }; } diff --git a/src/process/classifyProviderOutcome.ts b/src/process/classifyProviderOutcome.ts index c7c60887b1..364a45d735 100644 --- a/src/process/classifyProviderOutcome.ts +++ b/src/process/classifyProviderOutcome.ts @@ -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. */ diff --git a/tests/boundary/process/darwinProcessRunTokenReader.test.ts b/tests/boundary/process/darwinProcessRunTokenReader.test.ts index 8cfae8cf0b..7727e0b74a 100644 --- a/tests/boundary/process/darwinProcessRunTokenReader.test.ts +++ b/tests/boundary/process/darwinProcessRunTokenReader.test.ts @@ -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", );