diff --git a/packages/command-registry/src/timeout-policy.ts b/packages/command-registry/src/timeout-policy.ts index ca17ee37b7..30e6f58f61 100644 --- a/packages/command-registry/src/timeout-policy.ts +++ b/packages/command-registry/src/timeout-policy.ts @@ -5,7 +5,6 @@ import type { CommandTimeoutBudget, CommandTimeoutPolicy } from './types.ts'; // declared per command on the descriptors, so their values live beside them. const DAEMON_REQUEST_TIMEOUT_MS = 90_000; -export const PREPARE_REQUEST_TIMEOUT_MS = 240_000; // Keep this above the longest platform install subprocess timeout so the client // envelope does not abort a still-progressing device install first. @@ -16,6 +15,12 @@ export const INSTALL_REQUEST_TIMEOUT_MS = 180_000; // envelope below the command's declared base. const REQUEST_TIMEOUT_BUDGET_MARGIN_MS = 30_000; +/** Daemon-side runner budget for `prepare` without `--timeout` (`readPrepareIosRunnerTimeoutMs`). */ +export const PREPARE_STARTUP_BUDGET_MS = 240_000; + +export const PREPARE_REQUEST_TIMEOUT_MS = + PREPARE_STARTUP_BUDGET_MS + REQUEST_TIMEOUT_BUDGET_MARGIN_MS; + /** * How long a lease lifecycle provider may spend allocating one lease (cloud * device allocation: BrowserStack iOS ~45–90s, AWS remote access ~2 min to @@ -43,16 +48,11 @@ export const DEFAULT_TIMEOUT_POLICY: CommandTimeoutPolicy = { }; /** - * `fold`'s worst case sums every step budget on the route (platform-apple owns the constants; - * command-registry does not import them, so the figures below are copied, not derived): - * - display-inventory query (foldable check): 5s - * - fold-helper preparation (toolchain probe + build): 60s - * - HID dispatch (60s max keyframe duration + 10s): 70s - * - hinge settle reads (4 attempts x 20s): 80s - * - lit-panel display-inventory query: 5s - * total: 220s. The envelope below covers that with margin. + * Covers the fold route's worst-case ledger (display inventory, fold-helper preparation, HID + * dispatch, hinge settle reads, final display inventory) plus the daemon-result margin. Proven by + * the ledger test: `test/integration/provider-scenarios/ios-fold.test.ts`. */ -const FOLD_REQUEST_TIMEOUT_MS = 240_000; +const FOLD_REQUEST_TIMEOUT_MS = 255_000; export const FOLD_TIMEOUT_POLICY: CommandTimeoutPolicy = { budget: { source: 'none' }, diff --git a/src/__tests__/command-descriptor-timeout-policy.test.ts b/src/__tests__/command-descriptor-timeout-policy.test.ts index 906cf289c0..d8d444d278 100644 --- a/src/__tests__/command-descriptor-timeout-policy.test.ts +++ b/src/__tests__/command-descriptor-timeout-policy.test.ts @@ -138,15 +138,15 @@ test('settle timeout policy default matches the runtime settle loop default', () test('request envelopes deviating from the default are bounded, reviewed sets', () => { const EXPECTED_ENVELOPES: Record = { - prepare: 240_000, + // prepare: daemon-side runner budget (PREPARE_STARTUP_BUDGET_MS) plus the daemon-result margin. + prepare: 270_000, install: 180_000, reinstall: 180_000, install_source: 180_000, longpress: 210_000, - // fold: display-inventory query + fold-helper preparation + HID dispatch + hinge settle - // reads + lit-panel display-inventory query can sum to 220s worst case; the policy covers - // that with margin. - fold: 240_000, + // fold: proven by the worst-case ledger test in + // test/integration/provider-scenarios/ios-fold.test.ts. + fold: 255_000, // #1774: base allocation budget (300s) + client/daemon race margin (30s). lease_allocate: 330_000, test: 'unbounded', @@ -318,7 +318,7 @@ test('snapshot uses the standard daemon request timeout with an explicit overrid ...base, positionals: ['ios-runner'], }), - 240_000, + 270_000, ); assert.equal( resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('test'), { ...base }), diff --git a/src/daemon/handlers/__tests__/session-prepare.test.ts b/src/daemon/handlers/__tests__/session-prepare.test.ts new file mode 100644 index 0000000000..5804f8e8b5 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-prepare.test.ts @@ -0,0 +1,130 @@ +import { expect, test, vi } from 'vitest'; +import type { CommandFlags } from '@agent-device/contracts/command'; +import { resolveCommandTimeoutPolicy } from '@agent-device/command-registry/registry'; +import { resolveCommandRequestTimeoutMs } from '@agent-device/command-registry/timeout-policy'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type RuntimeFacts, +} from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import { IOS_SIMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import { createUnavailableRuntimeFactsForTest } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; +import type { + BindDeviceRuntime, + InspectDeviceRuntimeFacts, +} from '../../request-runtime-binding.ts'; +import { handlePrepareCommand } from '../session-prepare.ts'; + +// `resolveCommandDevice` widens to `resolveTargetDevice` for an explicit selector (this test +// always supplies `--udid`), which otherwise reaches the real device-selection dispatcher. +vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, resolveTargetDevice: vi.fn(async () => IOS_SIMULATOR) }; +}); + +// Matches REQUEST_TIMEOUT_BUDGET_MARGIN_MS, packages/command-registry/src/timeout-policy.ts. Not +// imported: it is not exported (fallow would flag an export used only by a test). +const REQUIRED_DAEMON_RESULT_MARGIN_MS = 30_000; + +function prepareRuntimeFacts(): RuntimeFacts { + const unavailableFacts = createUnavailableRuntimeFactsForTest( + IOS_SIMULATOR, + localRuntimeOwner('apple'), + ); + return { + ...unavailableFacts, + operations: { ...unavailableFacts.operations, prepareAppleRunner: { available: true } }, + }; +} + +/** + * Runs `prepare ios-runner` through the production handler with a fake runner binding that + * records the `timeoutMs` it was handed, then checks that value (plus the daemon-result margin) + * against the same request's resolved client envelope — the rule 1d proves, not just the + * constants it happens to compile to. + */ +async function runPrepare(flags: { timeoutMs?: number }): Promise<{ + handlerTimeoutMs: number; + envelopeMs: number | undefined; +}> { + const sessionName = 'prepare-envelope-margin'; + const sessionStore = makeSessionStore('agent-device-prepare-handler-'); + sessionStore.set(sessionName, { + name: sessionName, + device: IOS_SIMULATOR, + createdAt: Date.now(), + actions: [], + }); + + let handlerTimeoutMs: number | undefined; + const inspectFacts: InspectDeviceRuntimeFacts = async () => prepareRuntimeFacts(); + const bindDevice: BindDeviceRuntime = async (device, use) => + narrowDeviceBinding( + { + device, + owner: localRuntimeOwner('apple'), + facts: prepareRuntimeFacts(), + operations: { + prepareAppleRunner: async (input: { timeoutMs: number }) => { + handlerTimeoutMs = input.timeoutMs; + return { runner: {}, connectMs: 1, healthCheckMs: 1 }; + }, + }, + [Symbol.asyncDispose]: async () => {}, + }, + use, + ); + + const positionals = ['ios-runner']; + const requestFlags: CommandFlags = { + udid: IOS_SIMULATOR.id, + platform: 'ios', + ...flags, + }; + const response = await handlePrepareCommand({ + req: { + token: 't', + session: sessionName, + command: 'prepare', + positionals, + flags: requestFlags, + }, + sessionName, + logPath: '/dev/null', + sessionStore, + inspectFacts, + bindDevice, + }); + + expect(response?.ok, JSON.stringify(response)).toBe(true); + expect(handlerTimeoutMs).toBeDefined(); + + const envelopeMs = resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('prepare'), { + positionals, + flags: requestFlags, + }); + return { handlerTimeoutMs: handlerTimeoutMs!, envelopeMs }; +} + +test('prepare ios-runner with no --timeout keeps the daemon-result margin under the envelope', async () => { + const { handlerTimeoutMs, envelopeMs } = await runPrepare({}); + expect(envelopeMs).toBeDefined(); + expect(handlerTimeoutMs + REQUIRED_DAEMON_RESULT_MARGIN_MS).toBeLessThanOrEqual(envelopeMs!); +}); + +test('prepare ios-runner --timeout 300000 keeps the daemon-result margin under the envelope', async () => { + const { handlerTimeoutMs, envelopeMs } = await runPrepare({ timeoutMs: 300_000 }); + expect(envelopeMs).toBeDefined(); + expect(handlerTimeoutMs).toBe(300_000); + expect(handlerTimeoutMs + REQUIRED_DAEMON_RESULT_MARGIN_MS).toBeLessThanOrEqual(envelopeMs!); +}); + +test('prepare ios-runner --timeout 60000 keeps the daemon-result margin under the envelope', async () => { + const { handlerTimeoutMs, envelopeMs } = await runPrepare({ timeoutMs: 60_000 }); + expect(envelopeMs).toBeDefined(); + expect(handlerTimeoutMs).toBe(60_000); + expect(handlerTimeoutMs + REQUIRED_DAEMON_RESULT_MARGIN_MS).toBeLessThanOrEqual(envelopeMs!); +}); diff --git a/src/daemon/handlers/session-prepare.ts b/src/daemon/handlers/session-prepare.ts index 88e3f15623..0a6944b5fd 100644 --- a/src/daemon/handlers/session-prepare.ts +++ b/src/daemon/handlers/session-prepare.ts @@ -1,7 +1,7 @@ import { prepareAppleRunnerRuntimeUse } from '@agent-device/contracts/application-lifecycle-runtime-plan'; import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; -import { PREPARE_REQUEST_TIMEOUT_MS } from '@agent-device/command-registry/timeout-policy'; +import { PREPARE_STARTUP_BUDGET_MS } from '@agent-device/command-registry/timeout-policy'; import { publicPlatformString, type DeviceInfo } from '@agent-device/kernel/device'; import { resolveRunnerLogicalLeaseContext } from '../lease-context.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; @@ -87,7 +87,7 @@ function readPrepareIosRunnerTimeoutMs(req: DaemonRequest): number { const value = req.flags?.timeoutMs; return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value - : PREPARE_REQUEST_TIMEOUT_MS; + : PREPARE_STARTUP_BUDGET_MS; } function prepareIosRunnerResponseData( diff --git a/test/integration/provider-scenarios/ios-fold.test.ts b/test/integration/provider-scenarios/ios-fold.test.ts index 49beb7d31e..9fcd044559 100644 --- a/test/integration/provider-scenarios/ios-fold.test.ts +++ b/test/integration/provider-scenarios/ios-fold.test.ts @@ -1,13 +1,18 @@ import { formatPortableActionLine, parseReplayScriptDetailed } from '@agent-device/ad-script'; +import { resolveCommandTimeoutPolicy } from '@agent-device/command-registry/registry'; +import { resolveCommandRequestTimeoutMs } from '@agent-device/command-registry/timeout-policy'; +import { MAX_FOLD_DURATION_MS } from '@agent-device/contracts/device'; +import type { AppleToolProvider } from '@agent-device/platform-apple/tool-provider'; +import type { ExecResult } from '@agent-device/host-kit/command'; import { recordActionEntry } from '../../../src/daemon/session-action-recorder.ts'; -import { assertRpcOk } from './assertions.ts'; +import { assertRpcError, assertRpcOk } from './assertions.ts'; import { makeIosAppSession } from '../../../src/__tests__/test-utils/session-factories.ts'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, beforeEach, test } from 'vitest'; -import { createProviderScenarioHarness } from './harness.ts'; +import { afterEach, beforeEach, test, vi } from 'vitest'; +import { createProviderScenarioHarness, type ProviderScenarioRpcResult } from './harness.ts'; import { createRecordingAppleToolProvider } from './providers.ts'; import { PROVIDER_SCENARIO_IOS_SIMULATOR } from './fixtures.ts'; @@ -30,6 +35,38 @@ afterEach(() => { fs.rmSync(isolatedHome, { recursive: true, force: true }); }); +/** Writes the `devicectl … displays --json-output ` result both fold fakes below answer. */ +function writeDisplayInventoryFixture(jsonOutputPath: string): void { + const displays = [0, 1].map((displayId) => ({ + name: `LCD-${displayId}`, + displayId, + nativeSize: [2007, 2853], + pointScale: 3, + type: { integrated: {} }, + active: displayId === 1, + })); + fs.writeFileSync(jsonOutputPath, JSON.stringify({ result: { displays } })); +} + +/** + * Answers the host-toolchain probe (`xcodebuild -version`, `sw_vers`, `uname -m`) the fold-helper + * build cache (`fold-helper-cache.ts`) reads before it builds or reuses a cached binary. Both fold + * fakes below route these calls through the same `runCommand`. + */ +function toolchainProbeAnswer(cmd: string, args: readonly string[]): ExecResult | undefined { + if (cmd === 'xcodebuild') + return { stdout: 'Xcode 16.4\nBuild version 16F6', stderr: '', exitCode: 0 }; + if (cmd === 'sw_vers') { + return { + stdout: args.includes('-buildVersion') ? '24G90' : '15.6', + stderr: '', + exitCode: 0, + }; + } + if (cmd === 'uname') return { stdout: 'arm64', stderr: '', exitCode: 0 }; + return undefined; +} + test('timed fold keyframes reach simulator HID through the public client and daemon', async () => { const trajectory = [ { atMs: 0, angle: 0 }, @@ -49,18 +86,7 @@ test('timed fold keyframes reach simulator HID through the public client and dae devicectl: async (args) => { if (args.includes('hinge-angle')) return { ...ok, stdout: `Angle: ${angle}°`, exitCode: 1 }; assert.ok(args.includes('displays')); - const displays = [0, 1].map((displayId) => ({ - name: `LCD-${displayId}`, - displayId, - nativeSize: [2007, 2853], - pointScale: 3, - type: { integrated: {} }, - active: displayId === 1, - })); - fs.writeFileSync( - args[args.indexOf('--json-output') + 1]!, - JSON.stringify({ result: { displays } }), - ); + writeDisplayInventoryFixture(args[args.indexOf('--json-output') + 1]!); return ok; }, }); @@ -70,17 +96,8 @@ test('timed fold keyframes reach simulator HID through the public client and dae appleToolProvider: () => ({ ...tool.provider, runCommand: async (command, args) => { - if (command === 'xcodebuild') { - return { stdout: 'Xcode 16.4\nBuild version 16F6', stderr: '', exitCode: 0 }; - } - if (command === 'sw_vers') { - return { - stdout: args.includes('-buildVersion') ? '24G90' : '15.6', - stderr: '', - exitCode: 0, - }; - } - if (command === 'uname') return { stdout: 'arm64', stderr: '', exitCode: 0 }; + const probeAnswer = toolchainProbeAnswer(command, args); + if (probeAnswer) return probeAnswer; assert.equal(command, 'xcrun'); assert.ok(args.includes('clang')); builds++; @@ -124,3 +141,229 @@ test('timed fold keyframes reach simulator HID through the public client and dae await daemon.close(); } }); + +// The fold request envelope (`FOLD_REQUEST_TIMEOUT_MS`, packages/command-registry/src/ +// timeout-policy.ts) must cover the route's worst-case wall time plus the daemon-result margin +// (REQUEST_TIMEOUT_BUDGET_MARGIN_MS, same file), or a still-progressing fold trips the client +// envelope and resets the daemon before the route's own typed result arrives. This test proves +// that bound against the real route rather than a hand-summed comment: it never imports a +// platform-apple step figure, so it stays true however the route's steps change. +type FoldLedgerCall = Readonly<{ + tool: 'runCommand' | 'simctl' | 'devicectl'; + args: readonly string[]; + timeoutMs: number; + graceMs?: number; +}>; + +/** Two readings this far apart never settle (`IOS_FOLD_POSE_STABLE_DEGREES` is 0.5°), though each + * one alone matches the 100° target within it — the hinge keeps oscillating without resting. */ +function alternatingHingeAngle(readIndex: number): number { + return readIndex % 2 === 0 ? 99.6 : 100.4; +} + +function createFoldLedgerAppleToolProvider(params: { + hingeAngleAt: (readIndex: number) => number; + onCall: (call: FoldLedgerCall) => void; +}): { provider: AppleToolProvider; ledger: FoldLedgerCall[]; hingeReadCount: () => number } { + const ledger: FoldLedgerCall[] = []; + let hingeReads = 0; + const ok = { stdout: '', stderr: '', exitCode: 0 }; + // Every real call on the fold route (packages/platform-apple/src/foldable/simulator-hid.ts, + // core/tool-provider.ts, core/simctl.ts) carries a bounded timeoutMs. A call reaching this fake + // with none is not a worst case the envelope assertion below can see, so it must fail the test + // rather than cost 0 virtual ms. + const record = ( + tool: FoldLedgerCall['tool'], + args: readonly string[], + options?: { timeoutMs?: number; kill?: { graceMs: number } }, + ): void => { + assert.ok( + Number.isFinite(options?.timeoutMs) && options!.timeoutMs! > 0, + `fold ledger call has no bounded timeoutMs: ${tool} ${args.join(' ')}`, + ); + const call: FoldLedgerCall = { + tool, + args, + timeoutMs: options!.timeoutMs!, + ...(options?.kill ? { graceMs: options.kill.graceMs } : {}), + }; + ledger.push(call); + params.onCall(call); + }; + // Built on the shared recording provider so any call this route does not script (macosHelper, + // macosHost, plist, or an unexpected runCommand) throws instead of being silently answered. + const recording = createRecordingAppleToolProvider({ + simctl: async (args, options) => { + record('simctl', args, options); + return ok; + }, + devicectl: async (args, options) => { + record('devicectl', args, options); + if (args.includes('hinge-angle')) { + const angle = params.hingeAngleAt(hingeReads); + hingeReads += 1; + return { ...ok, stdout: `Angle: ${angle}°`, exitCode: 1 }; + } + assert.ok(args.includes('displays'), `unexpected devicectl call: ${args.join(' ')}`); + writeDisplayInventoryFixture(args[args.indexOf('--json-output') + 1]!); + return ok; + }, + }); + const provider: AppleToolProvider = { + ...recording.provider, + runCommand: async (cmd, args, options) => { + record('runCommand', [cmd, ...args], options); + const probeAnswer = toolchainProbeAnswer(cmd, args); + if (probeAnswer) return probeAnswer; + assert.equal(cmd, 'xcrun', `unexpected runCommand call: ${cmd} ${args.join(' ')}`); + assert.ok(args.includes('clang'), `unexpected xcrun call: ${args.join(' ')}`); + fs.writeFileSync(args.at(-1)!, 'fold-helper-binary'); + return ok; + }, + }; + return { provider, ledger, hingeReadCount: () => hingeReads }; +} + +/** + * Runs `fn` under its own throwaway `HOME`, so the fold-helper build cache under it + * (`~/.agent-device/fold-helper`) starts empty: the calibration and measured ledger runs each + * need a cold cache, not the outer per-test `HOME` the file's `beforeEach` already scoped, because + * a warm cache would skip the build phase and shrink the measured ledger. + */ +async function withColdFoldHelperCache(fn: () => Promise): Promise { + const outerHome = process.env.HOME; + const runHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-fold-run-home-')); + process.env.HOME = runHome; + try { + return await fn(); + } finally { + if (outerHome === undefined) delete process.env.HOME; + else process.env.HOME = outerHome; + fs.rmSync(runHome, { recursive: true, force: true }); + } +} + +/** + * Runs one cold fold through the public client and daemon against a fake Apple tool provider that + * records `{ tool, args, timeoutMs, graceMs }` per call. When `withVirtualClock` is set, `Date.now` + * is spied so every deadline the route reads (host-kit `Deadline`, snapshot-source/deadline.ts) + * sees each call as having spent `timeoutMs - 1` plus any kill grace: the worst case that still + * succeeds, never an actual timeout. + */ +async function runFoldLedgerScenario(params: { + hingeAngleAt: (readIndex: number) => number; + withVirtualClock: boolean; +}): Promise<{ + response: ProviderScenarioRpcResult; + ledger: FoldLedgerCall[]; + hingeReadCount: number; + virtualElapsedMs: number; +}> { + return withColdFoldHelperCache(async () => { + const trajectory = [ + { atMs: 0, angle: 0 }, + { atMs: MAX_FOLD_DURATION_MS, angle: 100 }, + ]; + let virtualElapsedMs = 0; + const originNowMs = Date.now(); + const dateSpy = params.withVirtualClock + ? vi.spyOn(Date, 'now').mockImplementation(() => originNowMs + virtualElapsedMs) + : undefined; + const { provider, ledger, hingeReadCount } = createFoldLedgerAppleToolProvider({ + hingeAngleAt: params.hingeAngleAt, + onCall: (call) => { + virtualElapsedMs += Math.max(0, call.timeoutMs - 1) + (call.graceMs ?? 0); + }, + }); + const daemon = await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], + appleToolProvider: () => provider, + }); + daemon.setSession( + 'default', + makeIosAppSession('default', { device: PROVIDER_SCENARIO_IOS_SIMULATOR }), + ); + try { + const response = await daemon.callCommand('fold', [], { + platform: 'ios', + udid: PROVIDER_SCENARIO_IOS_SIMULATOR.id, + keyframes: JSON.stringify(trajectory), + }); + return { response, ledger, hingeReadCount: hingeReadCount(), virtualElapsedMs }; + } finally { + dateSpy?.mockRestore(); + await daemon.close(); + } + }); +} + +function assertFoldLedgerPhaseCoverage(ledger: readonly FoldLedgerCall[]): void { + const isBuild = (call: FoldLedgerCall) => + call.tool === 'runCommand' && call.args.includes('clang'); + const isSpawn = (call: FoldLedgerCall) => call.tool === 'simctl' && call.args.includes('spawn'); + const isHingeRead = (call: FoldLedgerCall) => + call.tool === 'devicectl' && call.args.includes('hinge-angle'); + const isDisplayInventory = (call: FoldLedgerCall) => + call.tool === 'devicectl' && call.args.includes('displays'); + + assert.ok(ledger.some(isBuild), 'ledger is missing the helper-build phase'); + assert.ok(ledger.some(isSpawn), 'ledger is missing the HID-dispatch (simctl spawn) phase'); + assert.ok(ledger.some(isHingeRead), 'ledger is missing the hinge-settle-read phase'); + assert.equal( + ledger.filter(isDisplayInventory).length, + 2, + 'ledger must record both the foldable-check and the lit-panel display inventory reads', + ); +} + +test('fold worst-case ledger covers the client envelope with the daemon-result margin', async () => { + // Matches REQUEST_TIMEOUT_BUDGET_MARGIN_MS, packages/command-registry/src/timeout-policy.ts. + // Not imported: it is not exported (fallow would flag an export used only by a test). + const REQUIRED_DAEMON_RESULT_MARGIN_MS = 30_000; + + // Calibration: learns the route's settle-attempt budget (N) instead of assuming it. Needs no + // virtual clock, because only the read count is asserted here. + const calibration = await runFoldLedgerScenario({ + hingeAngleAt: alternatingHingeAngle, + withVirtualClock: false, + }); + const calibrationError = assertRpcError( + calibration.response, + 'COMMAND_FAILED', + /did not settle/, + ) as { details?: { reason?: unknown } }; + assert.equal(calibrationError.details?.reason, 'fold-pose-unsettled'); + const settleAttempts = calibration.hingeReadCount; + assert.ok( + settleAttempts >= 2, + `calibration run made ${settleAttempts} hinge reads; expected at least 2`, + ); + + // Measured: the same alternation for every read but the last, which lands exactly on target so + // the route settles on the very last read it allows — the worst case that still succeeds. + const measured = await runFoldLedgerScenario({ + hingeAngleAt: (readIndex) => + readIndex === settleAttempts - 1 ? 100 : alternatingHingeAngle(readIndex), + withVirtualClock: true, + }); + const measuredData = assertRpcOk(measured.response); + assert.equal(measuredData.hingeAngleDegrees, 100); + assert.equal( + measured.hingeReadCount, + settleAttempts, + 'the measured run must make exactly as many hinge reads as the calibration run', + ); + + assertFoldLedgerPhaseCoverage(measured.ledger); + + const envelopeMs = resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('fold'), { + positionals: [], + flags: {}, + }); + assert.ok(envelopeMs !== undefined, 'fold must declare a bounded envelope'); + assert.ok( + measured.virtualElapsedMs + REQUIRED_DAEMON_RESULT_MARGIN_MS <= envelopeMs!, + `fold ledger worst case (${measured.virtualElapsedMs}ms) + ${REQUIRED_DAEMON_RESULT_MARGIN_MS}ms margin ` + + `exceeds the ${envelopeMs}ms envelope.\nLedger:\n${JSON.stringify(measured.ledger, null, 2)}`, + ); +}, 20_000);