You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A command's request envelope is the client-side deadline. When it fires, onTimeout: 'reset-daemon' kills the daemon and every session it owns. The envelope must therefore stay longer than the worst case of the bounded work inside the request, plus the 30 s margin that lets the daemon's own typed result arrive first (REQUEST_TIMEOUT_BUDGET_MARGIN_MS, packages/command-registry/src/timeout-policy.ts:17).
Today nothing checks this. The fold envelope is a literal whose basis exists only in a comment, and that comment has already drifted. Line references are at origin/main6debef0634.
This issue makes the check executable for fold and prepare (Step 1). It does not close the bug class. The composite requests batch and replay run many steps inside one fixed envelope and are members of the same class (see "Composite requests" below and Step 2). The class stays open until Step 2 lands.
Fold (the reported case).timeout-policy.ts:45-50 sets FOLD_REQUEST_TIMEOUT_MS = 210_000 "with the usual margin". The real fold route (packages/platform-apple/src/foldable/pose.ts:45-56) runs these steps:
step
bound
source
display inventory (foldable check)
5 s
display-inventory.ts:102, config.ts:21
helper build (clang)
30 s
simulator-hid.ts:39
HID dispatch
MAX_FOLD_DURATION_MS + 10 s, plus a 1 s SIGTERM grace
The total is about 191 s. The comment leaves out both 5 s inventory queries and the kill grace, so the real margin is about 19 s, not 30 s. The pinning test's comment describes the wrong step: it says "one macOS helper press (30s)" (src/__tests__/command-descriptor-timeout-policy.test.ts:146-148).
No absolute overrun (envelope < worst case) exists for a standalone fold today. The defect is the margin shortfall, together with a check that exists only in a reviewer's head.
Prepare (same class, zero margin). With no --timeout, the daemon's runner budget is PREPARE_REQUEST_TIMEOUT_MS (readPrepareIosRunnerTimeoutMs, src/daemon/handlers/session-prepare.ts:86-91, fallback at line 90). The client envelope is the same constant (timeout-policy.ts:8, registry.ts:1040, resolveMarginEnvelopeTimeoutBudget at timeout-policy.ts:108-119). Both are 240 s. The client clock starts first, so a slow cold runner build ends in a client timeout and a daemon reset instead of the typed runner timeout. With --timeout X, the envelope is max(240 s, X + 30 s) (widenToUserBudget, timeout-policy.ts:131-133), so only the default case is broken. No handler-level prepare test exists today, so nothing ties the handler's fallback to the envelope.
Composite requests (same class, open overrun).fold spreads GENERIC_MUTATING_COMMAND_TRAITS (registry.ts:294-304, fold at registry.ts:1457-1467), so it is batchable: true and records a session action. It therefore appears in batch steps and in .ad scripts (test/integration/provider-scenarios/ios-fold.test.ts records and replays one). batch and replay run every step inside one client request, and neither holds a daemon-side deadline:
batch uses DEFAULT_TIMEOUT_POLICY, a fixed 90 s envelope for the whole request (registry.ts:1048-1060).
replay uses { ...DEFAULT_TIMEOUT_POLICY, budget: { source: 'flag' } } (registry.ts:852). Without --timeout the envelope is 90 s. With --timeout X the envelope is exactly X (the final branch of resolveFlagBudgetTimeoutMs, timeout-policy.ts:99), and no daemon code reads that budget, so the margin is zero by construction.
A fold step's worst case is about 191 s. A 60 s keyframe fold with no wedged step still takes about 70–85 s (60 s of motion, a helper build, and two to four 5 s hinge reads). A batch or replay that contains such a fold, or a longpress (210 s envelope, batchable) or install (180 s envelope, recorded), can pass its envelope and reset the daemon. Step 1 does not fix this; Step 2 owns it.
Precedent to follow.lease_allocate already has the right shape. The budget is one constant (LEASE_ALLOCATION_BUDGET_MS, timeout-policy.ts:25). It is handed to the daemon as a deadline (src/daemon/handlers/lease.ts:80), and the envelope is derived from it (timeout-policy.ts:27-28).
Inventory of hand-summed or unchecked envelopes (see Step 2). The non-default envelopes are enumerated by EXPECTED_ENVELOPES in src/__tests__/command-descriptor-timeout-policy.test.ts:139-161:
longpress 210 s (registry.ts:1305-1311): a comment sum of Android helper install 30 s (platform-android/src/touch-helper.ts:47), the 120 s hold cap (a literal at src/commands/interaction/runtime/gestures.ts:152), and 15 s helper overhead (touch-helper.ts:45). The comment also lists an unbounded "hand off a running snapshot helper" and the selector-resolution capture.
install / reinstall / install_source 180 s (timeout-policy.ts:10-12): "above the longest platform install subprocess timeout". The iOS value is IOS_DEVICE_INSTALL_TIMEOUT_MS = 120 s (platform-apple/src/core/config.ts:9, used at deployment/runtime.ts:156). Nothing checks this.
batch and replay use the default 90 s envelope, but their contents are other commands' work (above). They are members of this class, not "default" commands.
lease_allocate is derived. test is unbounded. The remaining commands use the default envelope for their own single-step work, which this issue does not audit.
Required behavior
Design constraints (why the check is a test, not a shared constant)
command-registry must not import platform-apple (layering, ADR 0019, R13 scripts/layering/platform-package-policy.ts).
packages/command-registry/src/timeout-policy.ts is a package entry surface, and its eager closure is exactly 1 module. scripts/__tests__/eager-closure-budgets.ts enforces NO GROWTH with no waiver. So timeout-policy.ts cannot gain any value import, whether a contracts constant or anything else.
registry.ts is 2,108 lines. ADR 0027 forbids splitting it and allows only closure-neutral edits.
A platform-apple export that only a test consumes fails check:production-exports (fallow production analysis).
Tests under test/ cannot import packages/*/src by relative path (R11, checkRootSites in scripts/layering/package-boundaries.ts:215-236). IOS_FOLD_POSE_SETTLE_ATTEMPTS and IOS_HINGE_ANGLE_TIMEOUT_MS (platform-apple/src/core/config.ts:27,29) are on no exported subpath, and exporting them for a test fails check:production-exports. Only MAX_FOLD_DURATION_MS is reachable, through @agent-device/contracts/device. So the test must learn the read count and per-read timeout from the route itself, never from a copied number.
test/integration/provider-scenarios/ already drives fold end to end, through the public client, the daemon, and the real platform-apple route, with a fake AppleToolProvider (ios-fold.test.ts). Every fold subprocess goes through that one seam: runXcrun / runAppleToolCommand in platform-apple/src/core/tool-provider.ts:109-128. fix(ios): drop -Werror from runtime clang builds and cache the fold helper build #2858 routes the helper cache there on purpose. This layer may import both command-registry (through its package subpaths) and the platform route, so the check belongs here.
Step 1 (this PR)
1a. Fold worst-case ledger test. Add one test to test/integration/provider-scenarios/ios-fold.test.ts.
The fake Apple tool provider: every runCommand / simctl.run / devicectl.run call does three things:
records { tool, args, timeoutMs, graceMs }
advances a virtual clock by timeoutMs - 1 plus kill.graceMs when set, where timeoutMs is the value the route passed on that call. Spy on Date.now only, restore it in finally, and do not fake timers. Every deadline in the route (host-kitDeadline, snapshot-source/deadline.ts) reads Date.now.
returns the success payload for that step.
The test runs fold twice, each time in a fresh harness with a cold helper cache (before #2858: the route builds every time; after #2858: a fresh temp cache dir per run, the way #2858's scenario does it, and the fake clang writes its output file).
Calibration run (learns the settle budget from the route). Keyframes whose last atMs is MAX_FOLD_DURATION_MS and whose last angle is a mid-range target, for example 100°. The fake hinge read alternates 99.6° and 100.4°. Each value matches the target within IOS_FOLD_POSE_STABLE_DEGREES (0.5°), but two consecutive values differ by 0.8°, so the hinge never settles. The route must refuse with the typed reason: 'fold-pose-unsettled' (pose.ts:217); assert the reason field, not the message text. Count the hinge-angle calls in this run as N, and assert N >= 2.
Measured run (the worst case). The same keyframes. Hinge reads 1 to N - 1 use the same alternation; read N returns exactly 100°, which is within 0.4° of either neighbour and of the target, so the pose settles on the last read the route allows. Assert the fold succeeds and that this run made exactly N hinge reads.
Assert: virtualElapsedMs + 30_000 <= resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('fold'), { positionals: [], flags: {} }), with resolveCommandRequestTimeoutMs from @agent-device/command-registry/timeout-policy and resolveCommandTimeoutPolicy from @agent-device/command-registry/registry.
Name the 30_000 locally as the required daemon-result margin and cite timeout-policy.ts. Do not export REQUEST_TIMEOUT_BUDGET_MARGIN_MS just for the test, because fallow would flag it as test-only.
Do not hard-code the read count, the per-read timeout, or any other platform-apple constant in the test. The only imported fold figure is MAX_FOLD_DURATION_MS.
Assert that the measured run's ledger contains at least one call from each phase: display inventory, helper build, simctl spawn, the hinge reads, and the final display inventory. A phase that someone mocks away must fail the test instead of shrinking the sum.
On failure, print the ledger (per-call timeoutMs) so the fixer sees which step grew.
1b. Fold envelope from the ledger. Set FOLD_REQUEST_TIMEOUT_MS to the smallest multiple of 5,000 that is at least virtualElapsedMs + 30_000:
Replace the hand-summed comment at timeout-policy.ts:45-49 with one line that points to the ledger test. Do the same for command-descriptor-timeout-policy.test.ts:146-148, and update its pinned value. Do not copy step figures into either comment, because the test is the source of truth.
1c. Prepare default budget with a margin. In timeout-policy.ts:
/** Daemon-side runner budget for `prepare` without `--timeout`. */exportconstPREPARE_STARTUP_BUDGET_MS=240_000;exportconstPREPARE_REQUEST_TIMEOUT_MS=PREPARE_STARTUP_BUDGET_MS+REQUEST_TIMEOUT_BUDGET_MARGIN_MS;// 270_000
readPrepareIosRunnerTimeoutMs (session-prepare.ts:86-91) falls back to PREPARE_STARTUP_BUDGET_MS. The runner budget stays 240 s, and the envelope becomes 270 s. prepare --timeout 240000 already resolves to 270 s (command-descriptor-timeout-policy.test.ts:357-363), so this change makes the default match the explicit case. timeout-policy.ts keeps zero imports.
1d. Prepare handler-level budget test. The rule to prove is about the production route, not the constants: for the same request input, the timeoutMs that handlePrepareCommand passes to prepareAppleRunner, plus the 30 s margin, must be at most resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('prepare'), input).
Add src/daemon/handlers/__tests__/session-prepare.test.ts (new; it mirrors session-prepare.ts). It calls handlePrepareCommand with an iOS simulator session, inspectFacts that admit prepareAppleRunnerRuntimeUse, and a fake bindDevice whose prepareAppleRunner records its input and returns a minimal result (the doctor handler test, session-doctor-app-runtime.test.ts:114, shows the fake runtime shape). Cases:
prepare ios-runner with no --timeout
prepare ios-runner --timeout 300000
prepare ios-runner --timeout 60000
For each case, compute the envelope from the same positionals and flags the handler received, and assert the rule. Name the 30 s margin locally, as in 1a. This test must fail on main (240 s + 30 s > 240 s in the default case) and pass after 1c. A test that compares only PREPARE_STARTUP_BUDGET_MS with the envelope is not enough, because it passes even if the handler still falls back to PREPARE_REQUEST_TIMEOUT_MS.
Step 2 (follow-up issues, not this PR)
File each item below as its own issue when Step 1 merges. Until they land, this bug class is not closed.
2a. Composite requests: batch and replay. Owner: the command-registry timeout policy (ADR 0008) together with the composite command owners, src/commands/batch + src/daemon/handlers/session-batch.ts, and src/commands/replay + src/daemon/handlers/session-replay-command.ts.
The rule to enforce: a composite request's envelope must cover its steps' worst case plus the 30 s margin, or the composite must hold a daemon-side deadline that ends at least 30 s before its envelope and returns a typed timeout (the lease_allocate shape). The follow-up chooses between these two designs:
Derived envelope. The client derives the composite envelope from the steps it sends. batch steps travel in the request (--steps / --steps-file), so the client can sum each step's resolved envelope. replay would need the client to parse the script first.
Daemon deadline.batch and replay get a daemon-side deadline; the envelope is that budget plus the margin. For replay, --timeout is documented as the "Maximum wall-clock duration for the replay request" (src/commands/replay/index.ts:69), so the daemon should own it, and the envelope must become X + 30 s (the margin budget mode) instead of exactly X.
Completion for 2a: a provider-scenario test that runs batch with one worst-case fold step (the 1a fake), and a replay of a one-line .ad fold script, with and without --timeout. Each must end in the daemon's typed result, never in a client timeout and daemon reset. test stays unbounded and out of scope.
2b. longpress. Apply the ledger pattern on the Android cold-helper route, with a 120 s hold. Also replace the gestures.ts:152 literal 120_000 with a named constant that both the validator and the scenario read.
2c. install / reinstall / install_source on iOS and Android.
For 2b and 2c, either prove the literal or correct it, and then make its comment point to the test.
Step 2 is separate because each item has a different owner and, for 2a, an open design choice. Folding it into this PR would cross command families (AGENTS.md: keep changes within one command family).
In the measured run, the hinge read count equals the calibration run's N, and the calibration run fails with reason: 'fold-pose-unsettled'.
Mutation checks, recorded in the PR, each reverted afterwards. Each makes the ledger test fail with a ledger printout:
raise IOS_HINGE_ANGLE_TIMEOUT_MS to 30,000
set IOS_FOLD_POSE_SETTLE_ATTEMPTS to 6 (the fake must follow the route to 6 reads and the sum must grow by two reads; a fake that settles on a fixed read count would pass here, and that is the defect this check catches)
add one more runXcrun call to the fold route with a 30 s timeout
Removing a phase from the fake provider's script fails the phase-coverage assertion.
The new session-prepare.test.ts (1d) fails on main in the default case and passes after 1c. Mutation check, recorded in the PR: after 1c, change the fallback at session-prepare.ts:90 back to PREPARE_REQUEST_TIMEOUT_MS, and the test fails. Revert afterwards.
Update EXPECTED_ENVELOPES (prepare: 270_000, new fold value) and the prepare case at command-descriptor-timeout-policy.test.ts:316-321.
timeout-policy.ts keeps an eager closure of 1, and no entry surface's closure grows (eager-closure-budgets green). registry.ts gets no new lines. The layering scan (R11) is green: no test imports packages/*/src by relative path.
pnpm check:affected --run, pnpm lint, pnpm typecheck, pnpm format, and check:production-exports are green.
The Step 2 issues (2a, 2b, 2c) are filed and linked here before this issue closes.
CHANGELOG entry: the fold and prepare request envelopes widen so the daemon's own timeout wins.
Non-goals
No shared budget contract in @agent-device/contracts and no platform-registration framework. The derived-constant route fails the eager-closure gate for timeout-policy.ts, and it needs a test-only platform export.
No runtime deadline threaded through the fold steps. That would make an overrun impossible rather than detectable, but it changes step semantics (a clipped hinge read). Propose it separately if the ledger ever proves too weak.
No change in this PR to batch or replay envelopes. They are in scope for this bug class, and Step 2a owns them.
ADR 0008: envelopes stay declared per command in the registry. This adds proof, not a new owner.
ADR 0019 / R13: no command-registry → platform edge. The check lives in test/integration/provider-scenarios.
R11 (scripts/layering/package-boundaries.ts): tests reach packages only through exported subpaths, which is why the ledger learns the settle budget from the route.
ADR 0027: no registry split and no closure growth. timeout-policy.ts stays import-free.
Gross diff: about 230–320 lines. The ledger test with its fake provider and two runs is about 130–180 lines, the new prepare handler test about 60–90, other test updates about 25, and production about 12.
Net production lines: about −2. The fold comment loses 3–4 lines, prepare gains 3–4, and session-prepare.ts changes 2.
Main risk: a Date.now spy inside the provider harness. If some daemon logic reads Date.now deltas during the request and misbehaves under a jump of about 250 s, scope the spy to the fake provider's lifetime and report what broke. Do not fake timers. The calibration run needs no virtual clock.
Purpose
A command's request envelope is the client-side deadline. When it fires,
onTimeout: 'reset-daemon'kills the daemon and every session it owns. The envelope must therefore stay longer than the worst case of the bounded work inside the request, plus the 30 s margin that lets the daemon's own typed result arrive first (REQUEST_TIMEOUT_BUDGET_MARGIN_MS,packages/command-registry/src/timeout-policy.ts:17).Today nothing checks this. The fold envelope is a literal whose basis exists only in a comment, and that comment has already drifted. Line references are at
origin/main6debef0634.This issue makes the check executable for
foldandprepare(Step 1). It does not close the bug class. The composite requestsbatchandreplayrun many steps inside one fixed envelope and are members of the same class (see "Composite requests" below and Step 2). The class stays open until Step 2 lands.Fold (the reported case).
timeout-policy.ts:45-50setsFOLD_REQUEST_TIMEOUT_MS = 210_000"with the usual margin". The real fold route (packages/platform-apple/src/foldable/pose.ts:45-56) runs these steps:display-inventory.ts:102,config.ts:21simulator-hid.ts:39MAX_FOLD_DURATION_MS+ 10 s, plus a 1 s SIGTERM gracecontracts/src/device-rotation.ts:128,simulator-hid.ts:56config.ts:27,29,hinge-angle.ts:46pose.ts:56The total is about 191 s. The comment leaves out both 5 s inventory queries and the kill grace, so the real margin is about 19 s, not 30 s. The pinning test's comment describes the wrong step: it says "one macOS helper press (30s)" (
src/__tests__/command-descriptor-timeout-policy.test.ts:146-148).COLD_TOOLCHAIN_PROBE_TIMEOUT_MS + FOLD_HELPER_BUILD_TIMEOUT_MS= 60 s). The author summed the steps again by hand, got 220 s, and raised the literal to 240 s. That is a 20 s margin. The next step change repeats the same manual work, and it can fail silently.Prepare (same class, zero margin). With no
--timeout, the daemon's runner budget isPREPARE_REQUEST_TIMEOUT_MS(readPrepareIosRunnerTimeoutMs,src/daemon/handlers/session-prepare.ts:86-91, fallback at line 90). The client envelope is the same constant (timeout-policy.ts:8,registry.ts:1040,resolveMarginEnvelopeTimeoutBudgetattimeout-policy.ts:108-119). Both are 240 s. The client clock starts first, so a slow cold runner build ends in a client timeout and a daemon reset instead of the typed runner timeout. With--timeout X, the envelope ismax(240 s, X + 30 s)(widenToUserBudget,timeout-policy.ts:131-133), so only the default case is broken. No handler-levelpreparetest exists today, so nothing ties the handler's fallback to the envelope.Composite requests (same class, open overrun).
foldspreadsGENERIC_MUTATING_COMMAND_TRAITS(registry.ts:294-304, fold atregistry.ts:1457-1467), so it isbatchable: trueand records a session action. It therefore appears inbatchsteps and in.adscripts (test/integration/provider-scenarios/ios-fold.test.tsrecords and replays one).batchandreplayrun every step inside one client request, and neither holds a daemon-side deadline:batchusesDEFAULT_TIMEOUT_POLICY, a fixed 90 s envelope for the whole request (registry.ts:1048-1060).replayuses{ ...DEFAULT_TIMEOUT_POLICY, budget: { source: 'flag' } }(registry.ts:852). Without--timeoutthe envelope is 90 s. With--timeout Xthe envelope is exactlyX(the final branch ofresolveFlagBudgetTimeoutMs,timeout-policy.ts:99), and no daemon code reads that budget, so the margin is zero by construction.A fold step's worst case is about 191 s. A 60 s keyframe fold with no wedged step still takes about 70–85 s (60 s of motion, a helper build, and two to four 5 s hinge reads). A
batchorreplaythat contains such a fold, or alongpress(210 s envelope, batchable) orinstall(180 s envelope, recorded), can pass its envelope and reset the daemon. Step 1 does not fix this; Step 2 owns it.Precedent to follow.
lease_allocatealready has the right shape. The budget is one constant (LEASE_ALLOCATION_BUDGET_MS,timeout-policy.ts:25). It is handed to the daemon as a deadline (src/daemon/handlers/lease.ts:80), and the envelope is derived from it (timeout-policy.ts:27-28).Inventory of hand-summed or unchecked envelopes (see Step 2). The non-default envelopes are enumerated by
EXPECTED_ENVELOPESinsrc/__tests__/command-descriptor-timeout-policy.test.ts:139-161:longpress210 s (registry.ts:1305-1311): a comment sum of Android helper install 30 s (platform-android/src/touch-helper.ts:47), the 120 s hold cap (a literal atsrc/commands/interaction/runtime/gestures.ts:152), and 15 s helper overhead (touch-helper.ts:45). The comment also lists an unbounded "hand off a running snapshot helper" and the selector-resolution capture.install/reinstall/install_source180 s (timeout-policy.ts:10-12): "above the longest platform install subprocess timeout". The iOS value isIOS_DEVICE_INSTALL_TIMEOUT_MS= 120 s (platform-apple/src/core/config.ts:9, used atdeployment/runtime.ts:156). Nothing checks this.batchandreplayuse the default 90 s envelope, but their contents are other commands' work (above). They are members of this class, not "default" commands.lease_allocateis derived.testis unbounded. The remaining commands use the default envelope for their own single-step work, which this issue does not audit.Required behavior
Design constraints (why the check is a test, not a shared constant)
command-registrymust not importplatform-apple(layering, ADR 0019, R13scripts/layering/platform-package-policy.ts).packages/command-registry/src/timeout-policy.tsis a package entry surface, and its eager closure is exactly 1 module.scripts/__tests__/eager-closure-budgets.tsenforces NO GROWTH with no waiver. Sotimeout-policy.tscannot gain any value import, whether a contracts constant or anything else.registry.tsis 2,108 lines. ADR 0027 forbids splitting it and allows only closure-neutral edits.check:production-exports(fallow production analysis).test/cannot importpackages/*/srcby relative path (R11,checkRootSitesinscripts/layering/package-boundaries.ts:215-236).IOS_FOLD_POSE_SETTLE_ATTEMPTSandIOS_HINGE_ANGLE_TIMEOUT_MS(platform-apple/src/core/config.ts:27,29) are on no exported subpath, and exporting them for a test failscheck:production-exports. OnlyMAX_FOLD_DURATION_MSis reachable, through@agent-device/contracts/device. So the test must learn the read count and per-read timeout from the route itself, never from a copied number.test/integration/provider-scenarios/already drives fold end to end, through the public client, the daemon, and the real platform-apple route, with a fakeAppleToolProvider(ios-fold.test.ts). Every fold subprocess goes through that one seam:runXcrun/runAppleToolCommandinplatform-apple/src/core/tool-provider.ts:109-128. fix(ios): drop -Werror from runtime clang builds and cache the fold helper build #2858 routes the helper cache there on purpose. This layer may import bothcommand-registry(through its package subpaths) and the platform route, so the check belongs here.Step 1 (this PR)
1a. Fold worst-case ledger test. Add one test to
test/integration/provider-scenarios/ios-fold.test.ts.The fake Apple tool provider: every
runCommand/simctl.run/devicectl.runcall does three things:{ tool, args, timeoutMs, graceMs }timeoutMs - 1pluskill.graceMswhen set, wheretimeoutMsis the value the route passed on that call. Spy onDate.nowonly, restore it infinally, and do not fake timers. Every deadline in the route (host-kitDeadline,snapshot-source/deadline.ts) readsDate.now.The test runs fold twice, each time in a fresh harness with a cold helper cache (before #2858: the route builds every time; after #2858: a fresh temp cache dir per run, the way #2858's scenario does it, and the fake clang writes its output file).
atMsisMAX_FOLD_DURATION_MSand whose lastangleis a mid-range target, for example 100°. The fake hinge read alternates 99.6° and 100.4°. Each value matches the target withinIOS_FOLD_POSE_STABLE_DEGREES(0.5°), but two consecutive values differ by 0.8°, so the hinge never settles. The route must refuse with the typedreason: 'fold-pose-unsettled'(pose.ts:217); assert the reason field, not the message text. Count the hinge-angle calls in this run asN, and assertN >= 2.N - 1use the same alternation; readNreturns exactly 100°, which is within 0.4° of either neighbour and of the target, so the pose settles on the last read the route allows. Assert the fold succeeds and that this run made exactlyNhinge reads.virtualElapsedMs + 30_000 <= resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('fold'), { positionals: [], flags: {} }), withresolveCommandRequestTimeoutMsfrom@agent-device/command-registry/timeout-policyandresolveCommandTimeoutPolicyfrom@agent-device/command-registry/registry.30_000locally as the required daemon-result margin and citetimeout-policy.ts. Do not exportREQUEST_TIMEOUT_BUDGET_MARGIN_MSjust for the test, because fallow would flag it as test-only.MAX_FOLD_DURATION_MS.simctl spawn, the hinge reads, and the final display inventory. A phase that someone mocks away must fail the test instead of shrinking the sum.timeoutMs) so the fixer sees which step grew.1b. Fold envelope from the ledger. Set
FOLD_REQUEST_TIMEOUT_MSto the smallest multiple of 5,000 that is at leastvirtualElapsedMs + 30_000:225_000on current main255_000if fix(ios): drop -Werror from runtime clang builds and cache the fold helper build #2858 lands first, replacing its 240 sReplace the hand-summed comment at
timeout-policy.ts:45-49with one line that points to the ledger test. Do the same forcommand-descriptor-timeout-policy.test.ts:146-148, and update its pinned value. Do not copy step figures into either comment, because the test is the source of truth.1c. Prepare default budget with a margin. In
timeout-policy.ts:readPrepareIosRunnerTimeoutMs(session-prepare.ts:86-91) falls back toPREPARE_STARTUP_BUDGET_MS. The runner budget stays 240 s, and the envelope becomes 270 s.prepare --timeout 240000already resolves to 270 s (command-descriptor-timeout-policy.test.ts:357-363), so this change makes the default match the explicit case.timeout-policy.tskeeps zero imports.1d. Prepare handler-level budget test. The rule to prove is about the production route, not the constants: for the same request input, the
timeoutMsthathandlePrepareCommandpasses toprepareAppleRunner, plus the 30 s margin, must be at mostresolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('prepare'), input).Add
src/daemon/handlers/__tests__/session-prepare.test.ts(new; it mirrorssession-prepare.ts). It callshandlePrepareCommandwith an iOS simulator session,inspectFactsthat admitprepareAppleRunnerRuntimeUse, and a fakebindDevicewhoseprepareAppleRunnerrecords its input and returns a minimal result (the doctor handler test,session-doctor-app-runtime.test.ts:114, shows the fake runtime shape). Cases:prepare ios-runnerwith no--timeoutprepare ios-runner --timeout 300000prepare ios-runner --timeout 60000For each case, compute the envelope from the same
positionalsandflagsthe handler received, and assert the rule. Name the 30 s margin locally, as in 1a. This test must fail on main (240 s + 30 s > 240 s in the default case) and pass after 1c. A test that compares onlyPREPARE_STARTUP_BUDGET_MSwith the envelope is not enough, because it passes even if the handler still falls back toPREPARE_REQUEST_TIMEOUT_MS.Step 2 (follow-up issues, not this PR)
File each item below as its own issue when Step 1 merges. Until they land, this bug class is not closed.
2a. Composite requests:
batchandreplay. Owner: the command-registry timeout policy (ADR 0008) together with the composite command owners,src/commands/batch+src/daemon/handlers/session-batch.ts, andsrc/commands/replay+src/daemon/handlers/session-replay-command.ts.The rule to enforce: a composite request's envelope must cover its steps' worst case plus the 30 s margin, or the composite must hold a daemon-side deadline that ends at least 30 s before its envelope and returns a typed timeout (the
lease_allocateshape). The follow-up chooses between these two designs:batchsteps travel in the request (--steps/--steps-file), so the client can sum each step's resolved envelope.replaywould need the client to parse the script first.batchandreplayget a daemon-side deadline; the envelope is that budget plus the margin. Forreplay,--timeoutis documented as the "Maximum wall-clock duration for the replay request" (src/commands/replay/index.ts:69), so the daemon should own it, and the envelope must becomeX + 30 s(themarginbudget mode) instead of exactlyX.Completion for 2a: a provider-scenario test that runs
batchwith one worst-case fold step (the 1a fake), and areplayof a one-line.adfold script, with and without--timeout. Each must end in the daemon's typed result, never in a client timeout and daemon reset.teststays unbounded and out of scope.2b.
longpress. Apply the ledger pattern on the Android cold-helper route, with a 120 s hold. Also replace thegestures.ts:152literal120_000with a named constant that both the validator and the scenario read.2c.
install/reinstall/install_sourceon iOS and Android.For 2b and 2c, either prove the literal or correct it, and then make its comment point to the test.
Step 2 is separate because each item has a different owner and, for 2a, an open design choice. Folding it into this PR would cross command families (AGENTS.md: keep changes within one command family).
Completion conditions
N, and the calibration run fails withreason: 'fold-pose-unsettled'.IOS_HINGE_ANGLE_TIMEOUT_MSto 30,000IOS_FOLD_POSE_SETTLE_ATTEMPTSto 6 (the fake must follow the route to 6 reads and the sum must grow by two reads; a fake that settles on a fixed read count would pass here, and that is the defect this check catches)runXcruncall to the fold route with a 30 s timeoutsession-prepare.test.ts(1d) fails on main in the default case and passes after 1c. Mutation check, recorded in the PR: after 1c, change the fallback atsession-prepare.ts:90back toPREPARE_REQUEST_TIMEOUT_MS, and the test fails. Revert afterwards.EXPECTED_ENVELOPES(prepare: 270_000, newfoldvalue) and thepreparecase atcommand-descriptor-timeout-policy.test.ts:316-321.timeout-policy.tskeeps an eager closure of 1, and no entry surface's closure grows (eager-closure-budgetsgreen).registry.tsgets no new lines. The layering scan (R11) is green: no test importspackages/*/srcby relative path.pnpm check:affected --run,pnpm lint,pnpm typecheck,pnpm format, andcheck:production-exportsare green.Non-goals
@agent-device/contractsand no platform-registration framework. The derived-constant route fails the eager-closure gate fortimeout-policy.ts, and it needs a test-only platform export.batchorreplayenvelopes. They are in scope for this bug class, and Step 2a owns them.openwithout--timeout(the 180 s boot inside a 90 s envelope is Cold iPhone startup ignores the requested preparation deadline #2324's decision), tolease_allocate(already derived), or totest(unbounded).onTimeoutpolicies.Dependencies / related
test/integration/provider-scenarios.scripts/layering/package-boundaries.ts): tests reach packages only through exported subpaths, which is why the ledger learns the settle budget from the route.timeout-policy.tsstays import-free.LEASE_ALLOCATION_BUDGET_MS(Cloud WebDriver session creation runs under the generic 30s request policy — BrowserStack iOSopenfails ~75% of the time and leaks billed provider sessions #1774).Cost
About 1–1.5 days for Step 1.
session-prepare.tschanges 2.Date.nowspy inside the provider harness. If some daemon logic readsDate.nowdeltas during the request and misbehaves under a jump of about 250 s, scope the spy to the fake provider's lifetime and report what broke. Do not fake timers. The calibration run needs no virtual clock.