Skip to content

fix(daemon): answer a named missing session on the session, not the device - #3701

Open
thymikee wants to merge 5 commits into
mainfrom
fix/3674-named-session-not-found
Open

thymikee wants to merge 5 commits into
mainfrom
fix/3674-named-session-not-found

Conversation

@thymikee

@thymikee thymikee commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #3674. Requests naming a missing daemon session receive SESSION_NOT_FOUND, reason: named_session_not_found, and dispatched: no before unconstrained device selection. Sessionless and intentionally targeted paths remain available. Apple appstate keeps the typed refusal because it cannot answer a target without a session; non-Apple identity selectors retain their runtime path. Batch provenance follows typed caller fields.

Validation

Current head: c6be0ce23f6e5425703bacba72291d45d4b7db1a (one test-only commit over 81b22ab3c).

  • Required exact-head gate: pnpm check:affected --run on c6be0ce23 → exit 0, all runnable checks passed, pre/post check head c6be0ce23 with a clean tree. CI on this head is still in flight; the earlier green 19+skip lane belongs to 81b22ab3c and is not claimed for this head.
  • Live repro at c6be0ce23 (repo-built dist, AGENT_DEVICE_STATE_DIR=$(mktemp -d), five booted iOS sims): snapshot --session never-opened --json answers SESSION_NOT_FOUND / named_session_not_found / dispatched: no in 390 ms cold (180 ms warm) with a request log holding no resolve phase, against a 2,388 ms host listing. snapshot --session never-opened --udid <A> resolves simulator A and answers the device's own ios_app_session_required — not AMBIGUOUS_MATCH; apps --session never-opened --udid <A> succeeds (832 ms). Sessionless snapshot --json still answers AMBIGUOUS_MATCH.
  • Regression pair: at base e8f24cdba, vitest run packages/daemon/src/server/__tests__/request-router-named-session.test.ts failed three assertions; at head ac9fb48f0 the same command passed all five (historical, earlier head).
  • Mutation facts at this head: removing the appstate named-session guard fails 4 tests; dropping the identity-selector exception fails the Android emulator-5554 positive; resolve-then-refuse with an identical payload fails only the new not.toHaveBeenCalled() pin.

…evice (#3674)

A command that names a session this daemon does not hold used to fall
through to resolveTargetDevice({}) — the session-attached command carries
no device selectors, so the device was picked from the host inventory.
With two devices booted the caller met AMBIGUOUS_MATCH telling it to
select a device; with one it met an app-session error. Either way the
answer depended on how many devices happened to be up and sent the
caller after a device selector when what it lost was its session.

Refuse at the seam instead: requireNamedSessionHoldsDevice throws
SESSION_NOT_FOUND with typed reason named_session_not_found and
dispatched=no, wired into every resolution that would otherwise choose a
device (snapshot, selector runtime, recording, sessionless close). A
request that never named a session stays sessionless by design, and one
that names a device selector as well keeps its stated target.

Provenance is traced, not inferred: namesSessionExplicitly reads
meta.sessionExplicit or a non-blank flags.session, replacing the two
copies of hasExplicitSessionFlag. It lives in @agent-device/kernel/
contracts, beside the meta field it reads, so every consumer already
evaluates it; the guard module reaches the hot seams through a
function-scoped import, keeping the eager-closure gate at parity
(ADR-0019 loading shape). The refusal is pinned by a router regression
test, unit tests, and a new daemon.route.named-session-refusal
dispatch-disclosure row.
#3674)

The batch runner stamped flags.session on every step so nested invokes route to the
parent's resolved address. The wire `session` field already does that; the flag is a
provenance carrier, and the new named-session refusal reads it. An unconditional stamp
therefore made a sessionless batch look caller-addressed and met SESSION_NOT_FOUND at
the daemon — an error no caller of the batch surface asked for, with advice ("omit
--session") the surface does not even offer. The stamp now follows the same traced
provenance as every other carrier: meta.sessionExplicit or a caller-set step flag.

Also from the same review: the named-session disclosure row's driver now pins the
producer's own code and typed reason, so a reverted guard cannot satisfy it with the
device-selection error it replaced; commands.md states which pre-dispatch refusals
answer session_or_device_selector_required even for a named-but-unheld session.
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-3701/

Built to branch gh-pages at 2026-10-16 11:50 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.24 MB 5.24 MB -4.5 kB
Package (unpacked) 5.24 MB 5.24 MB -4.5 kB
Package (download) 1.58 MB 1.57 MB -1.1 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.5 ms 27.6 ms +1.1 ms
CLI --help 75.6 ms 75.9 ms +0.3 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 25 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread packages/daemon/src/execution/selector-runtime-backend.ts Outdated
Comment thread packages/daemon/src/sessions/handlers/record-runtime.ts Outdated
Comment thread packages/daemon/src/sessions/handlers/session-appstate.ts Outdated
Comment thread packages/daemon/src/sessions/handlers/session-appstate.ts Outdated
…every seam

Round-2 review found #3674's refusal reaching some callers as an untyped
event. `get`/`is` answered a missing session with the generic "no active
session" error before the provenance guard could run; `record start` let
its app-scope INVALID_ARGS refusal win over a session that was gone
altogether; `appstate` carried no typed details and refused requests
that had also stated a concrete device. The refusal details now have one
producer, `namedSessionNotFoundDetails` beside its sibling builders, that
the guard's AppError and `appstate`'s platform-aware response both build
from; each seam orders the guard before its own missing-session refusal;
`appstate` preserves a request naming an identity selector, matching the
resolution guard. The guard module's function-scoped import is confined
to the missing-session branch it can fire on: that keeps the eager-
closure gate at parity and removes a first-time transform cost from the
held-session wait path, which under coverage instrumentation alone
overran `wait-runtime.test.ts`'s fake-timer budget at the unconditional
form (3/3 deterministic failures restored, 3/3 passes at this form).
`namesSessionExplicitly` now accepts the generic flag-record shape the
batch runner carries, fixing the TS2345 the batch call site raised.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 10 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed 7c3657d. The change has a defect: Apple appstate with a named missing session and a device selector now loses its typed reason, and the Coverage check fails on it.

appstate --session sim --platform ios --device "iPhone 17 Pro" with no held session now skips the named-session refusal because of the new !hasExplicitDeviceSelector(flags) at session-appstate.ts:69. It lands on appleSessionRequiredRefusal('ios') (session-appstate-apple-session.ts:37), which has a different message and no details.reason. On iOS a device selector cannot be answered without a session, so the exemption gains nothing there and only drops the typed reason. An Apple caller who names a lost session plus a device gets an untyped message instead of named_session_not_found, the reason this PR documents as the one to branch on. The existing test at session-appstate-input.test.ts:218-243 pins the named-session answer for this exact input and is the failing check. The rule should be: a named missing session answers named_session_not_found unless the command can serve the stated selector without a session. In appstate, please apply the identity-selector exemption only when the platform is not Apple (!isApplePlatform(flags.platform)), so that test and the new Android identity test both pass. Please do not rewrite the old test to match the new message.

This also changes what snapshot, wait, get, is, record and close answer on a device, but the PR body only cites fixture tests and a historical revert result at explicit-session-resolution.ts:50. No live run shows the issue's one-command repro, so nothing proves the real host listing is skipped. With two iOS simulators booted and AGENT_DEVICE_STATE_DIR=$(mktemp -d), please run agent-device snapshot --session never-opened --json and show SESSION_NOT_FOUND with details.reason named_session_not_found, well under the 3 s listing time. Then run agent-device snapshot --session never-opened --udid <A> and show that it resolves simulator A and not AMBIGUOUS_MATCH.

Not blocking, take or leave: the guard at explicit-session-resolution.ts:59 exempts any hasDeviceSelectionInput (including a bare --platform), while appstate exempts only hasExplicitDeviceSelector, so snapshot --session lost --platform ios with two simulators booted still lists devices and answers AMBIGUOUS_MATCH; one predicate owned in device-selector-intent.ts and documented in commands.md would fix both, and the control-flow and review-history comments in snapshot-session.ts, batch.ts, selector-runtime-backend.ts and record-runtime.ts can go once the guard has one owner.

Could the refusal live in one place in the router instead, after the locked binding resolves existingRef and before route dispatch? The conditions would be: no held session, namesSessionExplicitly(req), no device-target selector, and the command not declared as one that may run with a missing named session (open, replay, batch, lease, session list, devices, sessionless close). That would replace the four dynamic-import guards and the local appstate variant. noActiveSessionError and the requireSessionOrExplicitSelector answers would then carry named_session_not_found when a session was named, giving one typed shape for one state. It would need a request-policy trait in daemon-command-registry.ts declaring which commands may run with a named but missing session, so the router reads a declaration instead of each handler guarding.

The cubic-dev-ai thread on the router test still applies: request-router-named-session.test.ts:112-126 asserts the code and reason but not listings.count, so it would not catch a listing added before the guard.

The Coverage failure comes from this diff, not from a flake. I read the log and the head code and did not run the tests locally. I also did not check which clients send --platform on every read, so I cannot size how many callers hit the bare-platform gap. The revert evidence in the PR body is the author's earlier claim; I judged it by reading the assertions. There are no conflicts. Before merge, restore named_session_not_found for Apple appstate with a device selector so the Coverage test passes, then attach the live repro with two booted simulators.

…selector states

appstate's identity-selector exception over-preserved: a named missing session
with an explicit Apple device fell through to the session-required refusal, a
route with no sessionless answer. Keep the typed named-session refusal wherever
the command cannot serve the stated target without a session, and let only
non-Apple identity selectors keep their target.

Also pins the appstate refusal's typed reason/session/dispatched details, adds
the record seam's no-inventory assertion, and trims seam comments to their
invariants.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 9 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

@thymikee

Copy link
Copy Markdown
Member Author

Thanks for the update. The two simple-case fixes look right, but the Apple carve-out in appstate still misses a route, so the change is not ready at 81b22ab.

The check at packages/daemon/src/sessions/handlers/session-appstate.ts:68 only reads the --platform flag. Take appstate --session lost --udid <ios-sim-udid>, or --device "iPhone 17 Pro", with no --platform. It passes the guard, then resolveCommandDevice at :102 lists host devices and picks the simulator. queryDeviceRuntimeAppState (session-appstate-device-runtime.ts:28) then returns appleSessionRequiredRefusal('ios'), a SESSION_NOT_FOUND with no details.reason. So a caller with a lost session gets a device listing and an untyped refusal, not named_session_not_found. That also contradicts the commands.md line this PR adds. The rule should be: a request that names a missing session gets named_session_not_found unless the command can serve the stated target without a session. For appstate, only the resolved device knows that. I suggest appleSessionRequiredRefusal, or the call after resolution at :102, returns the named-session refusal when there is no session and namesSessionExplicitly(req) is true. Please add a test with { session: 'sim', udid: <ios udid> } and no platform. I read this from the head code and did not confirm that no CLI or client path fills in --platform first, so I rate it likely, not certain.

The live repro in the PR body is still pending, and this change affects snapshot, wait, get, is, record, close and appstate on real hosts (explicit-session-resolution.ts:47). Please boot two iOS simulators and set AGENT_DEVICE_STATE_DIR=$(mktemp -d). Then run agent-device snapshot --session never-opened --json. It should show SESSION_NOT_FOUND with details.reason named_session_not_found, dispatched no, and no listing delay (well under the ~3 s listing time). Run agent-device snapshot --session never-opened --udid <A> --json. It should resolve simulator A, not answer AMBIGUOUS_MATCH. Run agent-device appstate --session never-opened --udid <A> --json. It should answer named_session_not_found. I did not run the tests or the daemon.

The earlier question is still open and has no reply: could the router refuse once, after the binding resolves, when there is no held session, namesSessionExplicitly(req) is true, there is no identity selector, and the command lacks a "runs with a missing named session" trait in daemon-command-registry.ts (open, replay, batch, lease, session list, devices, sessionless close)? Could every session-required refusal (noActiveSessionError, requireSessionOrExplicitSelector, appleSessionRequiredRefusal) also carry named_session_not_found through one shared builder that takes the request's session provenance? That would remove the four dynamic-import guards and the appstate special case, and it would close the --udid gap by construction. The appstate-only carve-out leaves that gap open.

On the open threads: the P3 thread at #3701 (comment) is addressed, because request-router-named-session.test.ts:130 now asserts listings.count is 0 for record start, so you can resolve it. The P3 thread at #3701 (comment) still applies: session-appstate-input.test.ts:242-244 checks only the self-reported dispatched "no", so nothing pins that resolution was skipped, which is the route above.

The code does not pass yet because of the appstate route. Smoke Tests were still running when I checked, so I cannot attribute any CI result yet. Their route overlaps this diff (session resolution in snapshot-session.ts and selector-runtime-backend.ts), so a smoke failure there should be treated as real. Before merge, make Apple appstate answer named_session_not_found for --udid or --device with no --platform, then attach the live two-simulator run.

The appstate refusal reported dispatched: 'no', a value it writes itself, so the
test would still pass if the handler resolved a device before answering it. The
new assertion pins the behavior instead: resolving the target device never runs
on the named-session path. Reordering the guard to resolve-then-refuse with the
same code, message, reason and dispatched fields passes every other pin and
fails only this one.
@thymikee

Copy link
Copy Markdown
Member Author

Both requirements are addressed at c6be0ce23 (one test-only commit over 81b22ab3c).

Apple appstate typed reason. Restored in 81b22ab3c as you specified — the identity-selector exemption in session-appstate.ts now reads (!hasExplicitDeviceSelector(flags) || isApplePlatform(platform)), so an Apple caller with a named missing session plus a device selector keeps named_session_not_found, and the existing session-appstate-input.test.ts test was kept (only typed assertions added, message pins unchanged). The Android/HarmonyOS identity lane is unchanged and pinned by the emulator-5554 positive test. Live at this head: appstate --session never-opened --platform ios --device "iPhone 17 Pro" → SESSION_NOT_FOUND, details { reason: named_session_not_found, dispatched: "no", session: "never-opened" }.

Live repro (repo-built node bin/agent-device.mjs at c6be0ce23, version 0.21.29-dev; the homebrew 0.21.11 on PATH was not used; AGENT_DEVICE_STATE_DIR=$(mktemp -d), no device boot/reset, five booted iOS sims + one booted physical found on the host):

  • snapshot --session never-opened --json → SESSION_NOT_FOUND / named_session_not_found / dispatched: "no" in 390 ms cold (daemon spawned by the call), 180 ms warm, against a 2,388 ms devices --platform ios listing of the same pool. The request log for the refusal contains only request_start and request_failed — no resolve_target_device phase, so the host listing was really skipped.
  • snapshot --session never-opened --udid <A> → not AMBIGUOUS_MATCH: the request log shows resolve_target_device (276 ms, cacheHit=false) resolving simulator A, then the device's own ios_app_session_required. Cheaper end-to-end proof on the same routing lane: apps --session never-opened --udid <A> succeeds (832 ms, lists A's installed apps).
  • Control: sessionless snapshot --json still lists and answers AMBIGUOUS_MATCH (5 devices), so the intentional sessionless path is preserved.

Take-or-leave items. The single-predicate/bare-platform gap and the router-central guard with a daemon-command-registry request-policy trait are real directions; both are refusal-taxonomy changes across every command, so we recorded them as follow-ups rather than widening this PR — happy to see a follow-up issue filed for the device-selector-intent.ts predicate. The remaining cubic thread (listings.count for record start) is pinned since 81b22ab3c and was resolved with that citation; the appstate self-reported-dispatched thread is fixed at c6be0ce23 with a not.toHaveBeenCalled() pin whose mutation evidence is in the last commit's message and the PR body.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A session lost to a daemon takeover ("Replacing daemon … unreachable") answers the next request with AMBIGUOUS_MATCH when two simulators are booted

1 participant