Skip to content

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

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

thymikee wants to merge 4 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. Held-session waits avoid the missing-session guard import.

Validation

Current head: 81b22ab3c3b301fb482eda3628686c7bb5567d2e.

  • Typecheck/package, integration, repository guards, docs, size and Linux smoke passed. Coverage and Android/iOS/macOS smoke were pending at observation.
  • The required exact-head pnpm check:affected --run is queued after its clean-tree/SHA precheck. No full-gate pass is claimed.
  • Historical regression pair: base e8f24cdba failed three assertions; earlier fixed head ac9fb48f0 passed all five. This is not current-head gate evidence.
  • This head restores Apple appstate's typed refusal and adds the record-start inventory-zero assertion. The new appstate no-resolution assertion is under review.
  • Fresh independent final-head review and the requested live two-simulator repro remain pending. Review replies and final validation will cite measured results.

…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 08:07 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 28.8 ms 27.9 ms -0.9 ms
CLI --help 80.0 ms 79.4 ms -0.7 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.

1 issue found across 10 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/daemon/src/server/__tests__/request-router-named-session.test.ts">

<violation number="1" location="packages/daemon/src/server/__tests__/request-router-named-session.test.ts:112">
P3: This test can pass even if `record start` lists host devices before returning the refusal. Assert `listings.count` is zero to pin the pre-resolution behavior for this seam.</violation>
</file>

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

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

if (response.ok) return;
expect(response.error.code).toBe('SESSION_NOT_FOUND');
expect(response.error.details?.reason).toBe(PRE_DISPATCH_REFUSAL_REASONS.namedSessionNotFound);
expect(response.error.details?.session).toBe(LOST_SESSION);

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.

P3: This test can pass even if record start lists host devices before returning the refusal. Assert listings.count is zero to pin the pre-resolution behavior for this seam.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/daemon/src/server/__tests__/request-router-named-session.test.ts, line 112:

<comment>This test can pass even if `record start` lists host devices before returning the refusal. Assert `listings.count` is zero to pin the pre-resolution behavior for this seam.</comment>

<file context>
@@ -90,6 +96,39 @@ test('the refusal answers without listing the host devices at all', async () =>
+  if (response.ok) return;
+  expect(response.error.code).toBe('SESSION_NOT_FOUND');
+  expect(response.error.details?.reason).toBe(PRE_DISPATCH_REFUSAL_REASONS.namedSessionNotFound);
+  expect(response.error.details?.session).toBe(LOST_SESSION);
+  expect(listings.count).toBe(0);
+});
</file context>
Suggested change
expect(response.error.details?.session).toBe(LOST_SESSION);
expect(response.error.details?.session).toBe(LOST_SESSION);
expect(listings.count).toBe(0);

@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.

1 issue found across 9 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/daemon/src/sessions/handlers/__tests__/session-appstate-input.test.ts">

<violation number="1" location="packages/daemon/src/sessions/handlers/__tests__/session-appstate-input.test.ts:244">
P3: This checks only the refusal's self-reported `dispatched` value, so the test would miss resolving a device before returning it. Also assert `mockResolveTargetDevice` was not called to pin the pre-selection refusal.</violation>
</file>

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

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

expect(response.error.message).not.toMatch(/omit --session/i);
expect(response.error.details?.reason).toBe(PRE_DISPATCH_REFUSAL_REASONS.namedSessionNotFound);
expect(response.error.details?.session).toBe('sim');
expect(response.error.details?.dispatched).toBe('no');

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.

P3: This checks only the refusal's self-reported dispatched value, so the test would miss resolving a device before returning it. Also assert mockResolveTargetDevice was not called to pin the pre-selection refusal.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/daemon/src/sessions/handlers/__tests__/session-appstate-input.test.ts, line 244:

<comment>This checks only the refusal's self-reported `dispatched` value, so the test would miss resolving a device before returning it. Also assert `mockResolveTargetDevice` was not called to pin the pre-selection refusal.</comment>

<file context>
@@ -239,6 +239,9 @@ test('appstate with explicit missing session returns SESSION_NOT_FOUND', async (
     expect(response.error.message).not.toMatch(/omit --session/i);
+    expect(response.error.details?.reason).toBe(PRE_DISPATCH_REFUSAL_REASONS.namedSessionNotFound);
+    expect(response.error.details?.session).toBe('sim');
+    expect(response.error.details?.dispatched).toBe('no');
   }
 });
</file context>
Suggested change
expect(response.error.details?.dispatched).toBe('no');
expect(response.error.details?.dispatched).toBe('no');
expect(mockResolveTargetDevice).not.toHaveBeenCalled();

@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.

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