Skip to content

[agent-cli] Runtime commands decide which apps a run reads once per connection - #202

Merged
vonovak merged 4 commits into
mainfrom
vonovak/runtime-run-scope
Oct 30, 2026
Merged

vonovak merged 4 commits into
mainfrom
vonovak/runtime-run-scope

Conversation

@vonovak

@vonovak vonovak commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Every runtime command acts on the device bound to this worktree, and nothing else attached to the same dev server, and says which device it acted on.

This PR gives every runtime command one decision about which apps a run reads, so each layer above changes one place.

Stack: #202 → #203 → #204 → #206 → #217 → #218 → #207 → #200 (this is 1/8).

Problem

On main, each runtime caller (reads, waits, the bundle check, smoke) builds its own scope from the flags, and the device facts from simctl and adb are mixed with decisions. smoke also keeps a failed device lookup for the rest of the run, so a later phase cannot recover.

Fix

TargetSelection is the run's scope, DeviceInventory holds the device tools' facts, and one resolver classifies the targets. RuntimeConnection carries the classified targets, and every reader, wait and deferred command reads classifiedTargets.read. An attachment wait keeps the facts for an unchanged list and reads them again, within its budget, for a changed list. smoke keeps a successful device choice until a boot or a session start replaces it, and does not keep a failed lookup. The rules are in llp/0005 §Which app a run reads.

Red test: smoke targetSelection-test "asks the device tools once for a list that does not change" (4 simctl calls before the fix, 1 after). Also: "retries a failed device lookup, shares concurrent discovery, and retains the successful choice".

Live check: run at the stack top only, not at this layer. The local iOS simulator runs (#207, #218) and the EAS session runs (#203, #200) go through this layer's scope, classifier and inventory refresh. None of them counted device-tool calls or forced a failed lookup, so this layer's claim rests on its red tests.

Watch: an unscoped wait now asks the device tools once for each changed list that holds an unplaced target.

🤖 Generated with Claude Code

@vonovak
vonovak marked this pull request as ready for review October 24, 2026 03:41
@vonovak
vonovak requested a review from Kudo October 24, 2026 03:41
@vonovak
vonovak marked this pull request as draft October 24, 2026 05:08
vonovak added a commit that referenced this pull request Oct 24, 2026
…with or without a platform flag (llp/0005 §Pointing an app at this dev server)

Problem (N3-25): right after `dev --ios --eas`, a `navigate` with no
platform flag waited 120 s and exited 22. The run decided "an app is
attached" from the platform flag, before the device was known. With no
flag it counted the debugger targets of every platform, so a local
simulator's app on the same Metro counted as attached, and the launcher
open for the session did not happen. A no-flag navigate to a local
Android emulator with only an iOS app attached made the same mistake.
`navigate --ios` that fell back to an EAS Simulator iOS session without
`--eas` had the reverse fault: the flag scope cannot place the session's
app, so the run reopened an attached app through the launcher.

Change: `openRouteAsync` builds one attach waiter from the scope of the
device it resolved, and asks it whether an app is attached before it
decides on the launcher open. The same waiter serves the launcher wait
and the route-link wait, and reads the device facts once, at its first
probe. The route resolver no longer reports an attached flag. llp/0005
states the rule in the section on pointing an app at this dev server.

This commit sits on the batch-2 stack (#202-#207, RunScope in
src/runtime/runScope.ts); its base is vonovak/fix-reload-skipped-apps.

Red before: navigateAsync on a development build that is not loaded >
opens the launcher URL first, when no platform is named and only an iOS
app is attached; navigateAsync on an EAS Simulator session > opens, with
no flag, an Android session, only an iOS app attached; navigateAsync on
an EAS Simulator session > opens, with --ios without --eas, an iOS
session, its app attached

Guard: navigateAsync on an EAS Simulator session > opens, with no flag,
an Android session, its app attached; navigateAsync on an EAS Simulator
session > opens, with no flag, an iOS session, its app attached

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vonovak
vonovak added this pull request to stack #210 October 26, 2026 09:54
@vonovak
vonovak force-pushed the vonovak/runtime-run-scope branch from 774adb1 to c1ac723 Compare October 26, 2026 17:44
vonovak added a commit that referenced this pull request Oct 26, 2026
…with or without a platform flag (llp/0005 §Pointing an app at this dev server)

Problem (N3-25): right after `dev --ios --eas`, a `navigate` with no
platform flag waited 120 s and exited 22. The run decided "an app is
attached" from the platform flag, before the device was known. With no
flag it counted the debugger targets of every platform, so a local
simulator's app on the same Metro counted as attached, and the launcher
open for the session did not happen. A no-flag navigate to a local
Android emulator with only an iOS app attached made the same mistake.
`navigate --ios` that fell back to an EAS Simulator iOS session without
`--eas` had the reverse fault: the flag scope cannot place the session's
app, so the run reopened an attached app through the launcher.

Change: `openRouteAsync` builds one attach waiter from the scope of the
device it resolved, and asks it whether an app is attached before it
decides on the launcher open. The same waiter serves the launcher wait
and the route-link wait, and reads the device facts once, at its first
probe. The route resolver no longer reports an attached flag. llp/0005
states the rule in the section on pointing an app at this dev server.

This commit sits on the batch-2 stack (#202-#207, RunScope in
src/runtime/runScope.ts); its base is vonovak/fix-reload-skipped-apps.

Red before: navigateAsync on a development build that is not loaded >
opens the launcher URL first, when no platform is named and only an iOS
app is attached; navigateAsync on an EAS Simulator session > opens, with
no flag, an Android session, only an iOS app attached; navigateAsync on
an EAS Simulator session > opens, with --ios without --eas, an iOS
session, its app attached

Guard: navigateAsync on an EAS Simulator session > opens, with no flag,
an Android session, its app attached; navigateAsync on an EAS Simulator
session > opens, with no flag, an iOS session, its app attached

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vonovak
vonovak force-pushed the vonovak/runtime-run-scope branch from c1ac723 to dac1651 Compare October 26, 2026 18:24
vonovak added a commit that referenced this pull request Oct 26, 2026
…with or without a platform flag (llp/0005 §Pointing an app at this dev server)

Problem (N3-25): right after `dev --ios --eas`, a `navigate` with no
platform flag waited 120 s and exited 22. The run decided "an app is
attached" from the platform flag, before the device was known. With no
flag it counted the debugger targets of every platform, so a local
simulator's app on the same Metro counted as attached, and the launcher
open for the session did not happen. A no-flag navigate to a local
Android emulator with only an iOS app attached made the same mistake.
`navigate --ios` that fell back to an EAS Simulator iOS session without
`--eas` had the reverse fault: the flag scope cannot place the session's
app, so the run reopened an attached app through the launcher.

Change: `openRouteAsync` builds one attach waiter from the scope of the
device it resolved, and asks it whether an app is attached before it
decides on the launcher open. The same waiter serves the launcher wait
and the route-link wait, and reads the device facts once, at its first
probe. The route resolver no longer reports an attached flag. llp/0005
states the rule in the section on pointing an app at this dev server.

This commit sits on the batch-2 stack (#202-#207, RunScope in
src/runtime/runScope.ts); its base is vonovak/fix-reload-skipped-apps.

Red before: navigateAsync on a development build that is not loaded >
opens the launcher URL first, when no platform is named and only an iOS
app is attached; navigateAsync on an EAS Simulator session > opens, with
no flag, an Android session, only an iOS app attached; navigateAsync on
an EAS Simulator session > opens, with --ios without --eas, an iOS
session, its app attached

Guard: navigateAsync on an EAS Simulator session > opens, with no flag,
an Android session, its app attached; navigateAsync on an EAS Simulator
session > opens, with no flag, an iOS session, its app attached

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vonovak
vonovak removed this pull request from stack #210 October 27, 2026 17:52
@vonovak
vonovak force-pushed the vonovak/runtime-run-scope branch from dac1651 to a8a88f6 Compare October 27, 2026 17:52
@vonovak
vonovak added this pull request to stack #212 October 27, 2026 17:53
@vonovak vonovak changed the title [agent-cli] Runtime reads place their debugger targets from device facts and one run scope (refactor) [agent-cli] Resolve runtime scope and placement once per target list Oct 27, 2026
@vonovak
vonovak force-pushed the vonovak/runtime-run-scope branch from a8a88f6 to 04107af Compare October 27, 2026 21:29
@vonovak vonovak changed the title [agent-cli] Resolve runtime scope and placement once per target list [agent-cli] Resolve runtime target selection once per connection Oct 27, 2026
@vonovak
vonovak removed this pull request from stack #212 October 27, 2026 22:47
@vonovak
vonovak added this pull request to stack #219 October 27, 2026 22:47
@vonovak
vonovak force-pushed the vonovak/runtime-run-scope branch from 193082d to aec16f4 Compare October 29, 2026 03:54
@vonovak vonovak changed the title [agent-cli] Resolve runtime target selection once per connection [agent-cli] Runtime commands decide which apps a run reads once per connection Oct 29, 2026
@vonovak
vonovak marked this pull request as ready for review October 29, 2026 15:16
@vonovak
vonovak force-pushed the vonovak/runtime-run-scope branch from bfeb62d to 7e65430 Compare October 29, 2026 18:46
// Android-only break with an iOS app also attached was reported as "compiles for ios".
const targetSelection = targetSelectionOf({
flags: {
platform: options.platformExplicit && options.platform !== 'web' ? options.platform : null,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[bug] dev:wait --platform web no longer checks the web bundle once a native app is connected. targetSelectionOf cannot carry web (NavigatePlatform is only ios | android), and this call drops web before building the selection. resolveBundleCheckPlatformsAsync then treats a null platform as "infer from /json/list". Expo Go is placed by app id with no device facts, so a connected iOS or Android target makes the command build that native bundle (source: connected-app) instead of web. The previous call passed options.platformExplicit ? options.platform : null, so an explicit web platform was honored. Only the attach wait excluded web, because /json/list cannot see a browser (F40). A broken web bundle can now pass, and a broken native bundle can fail a web wait.

Suggestion: Keep this native selection for the attach wait only. When platformExplicit is set, resolve bundle platforms from options.platform directly, including web. Use the selection for inference only when the caller did not name a platform.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9073a3c

options.platform
);
await resolveBundleCheckPlatformsAsync(targetSelection, discovery.targets, {
deviceInventory: EMPTY_DEVICE_INVENTORY,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[bug] An unscoped dev:wait bundle check always receives EMPTY_DEVICE_INVENTORY, so it never asks simctl or adb. The old resolveBundleCheckPlatformsAsync called buildDeviceNameIndexIfNeededAsync whenever no platform was named. An iOS development build is placed only by a booted simulator name, and an Android name that is not the ... - API N shape is placed only by the adb model list. Those targets now stay undetermined. A dev server with an iOS dev client and Android Expo Go is checked as Android only, and an Android dev client named like Pixel 7 falls back to the default platform. That is the F53 miss this call exists to avoid. The --require-app path a few lines below already reads inventory for the same targets, and reload and interact pass the preflight inventory.

Suggestion: Pass await deviceInventoryForSelectionAsync(discovery.targets, targetSelection) into the bundle check. An empty inventory is only correct when the caller named a native platform and connected apps are not consulted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9073a3c

return previous;
}
const tools = await readDeviceInventoryAsync(timeoutMs);
return { ...previous, booted: tools.booted, androidModels: tools.androidModels };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[suggestion] A failed device-tool read is stored as an empty inventory and replaces facts that already placed the previous list. readDeviceInventoryAsync maps a non-zero exit, a spawn error, and a timeout to booted: [] and androidModels: [], and this return always overwrites previous. scopedListClassifier also commits the new list key before that read finishes (waitReady.ts, the inventoryListKey = key assignment), so those same ids are not read again. One fast simctl or adb failure after the target list changes can un-place a development build the previous facts already matched, and the rest of that wait keeps the empty facts.

Suggestion: On a failed tool read, keep previous and leave the list key unchanged so a later poll can retry. Replace booted and androidModels only when that command exited 0.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 18bc039 and 6851f00 (#203)

vonovak and others added 4 commits October 30, 2026 10:07
Add TargetSelection (the run's scope), DeviceInventory (the device tools'
facts) and one resolver. RuntimeConnection carries the classified targets.
Every reader, wait and deferred command reads classifiedTargets.read.
Clients, bundle checks and waits get the scope and the facts as arguments.
READ_EVERY_TARGET and makeTargetSelection replace the old flag helpers.

targetSelectionOf builds the scope from the flags and the device and reads
nothing; resolveTargetSelectionAsync and dev:wait use it.
refreshDeviceInventoryAsync is the one place that asks the device tools
again, and toolsCanPlace is the one predicate that says when to.

An attachment wait classifies every list it polls with scopedListClassifier.
The device facts belong to the list they were read for: an unchanged list
keeps them, and a changed list is read again within the wait's budget. A
target that nothing places costs one tool read, not one per poll.

Smoke keeps its device choice in smoke/targetSelection.ts. A successful
choice stays until a boot or a session start replaces it. A failed lookup
is not kept, so a later phase asks again. Placement does not change.

llp/0005 §Which app a run reads holds the scope table and the rules every
caller follows, each with its test.

Connected-app polling moves beside the other waits, so reload stays under
its cap. smokeAsync.ts leaves the long-file list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The two classifier rows and the smoke bundle-platform case become e2e cases: runtime:reload with no
platform checks the bundle on both platforms, and runtime:eval --android reads only the app it places
on Android.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The classifier rows and the smoke bundle-platform case moved to e2e; the scope table and the bundle
inference bullet now cite those e2e cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…coped wait places apps by device facts (llp/0005 §Android)

dev:wait is deferred code: src/deferred is excluded from vitest and its mocks no longer
resolve, so there is no runnable test. A named platform now skips inference; the
unscoped check reads deviceInventoryForSelectionAsync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vonovak
vonovak force-pushed the vonovak/runtime-run-scope branch from 0aa2bd6 to 9073a3c Compare October 30, 2026 09:09
@vonovak
vonovak merged commit 5f4df68 into main Oct 30, 2026
13 checks passed
@vonovak
vonovak deleted the vonovak/runtime-run-scope branch October 30, 2026 10:11
vonovak added a commit that referenced this pull request Oct 30, 2026
…ps on local devices (#203)

**Every runtime command acts on the device bound to this worktree, and
nothing else attached to the same dev server, and says which device it
acted on.**

This PR makes an EAS Simulator scope read the session's app and skip the
apps that local devices run.

Stack: #202 → #203 → #204 → #206 → #217 → #218 → #207 → #200 (this is
2/8).

## Problem

The app on an EAS Simulator iOS session registers under the cloud
simulator's model name, for example `iPhone 17`. No local tool reports
that name, so nothing placed the target (DF3, N2-06, N2-09).
`runtime:tree --ios` refused with "No app connected", and smoke reported
`app` unknown after 192 s.

## Fix

On an EAS Simulator scope for iOS, or with no platform flag, a target
that a local device tool names is skipped. The one target that nothing
places is the session's app. While the scope's device tools give no
answer, the targets stay undetermined. `--eas` alone is a cloud scope.
An attachment wait reads the facts again for a changed list. It asks a
tool that gave no answer again at most once a second, and its first read
counts as the first ask.

Red tests: runtime targetSelection-test "holds every unclaimed target
while the local simulators are unknown"; smoke targetSelection-test
"asks a failing simctl again at most once a second while the wait
polls"; preflight-test "should refuse two apps nothing places, and say
how to name the session's platform"; e2e cloud-simulator-test "reloads
the session's app, not the app on a booted local simulator" and "reads
neither unclaimed app with no platform".

Live check: at the stack top, on an EAS iOS session (Expo Go, v2 tunnel)
with a local simulator app on the same dev server: `runtime:reload
--eas` skipped the local app as `other-device` and proved 2 of 2 reloads
by `debugger-context-replaced`; `runtime:reload --ios` skipped the cloud
app and proved 3 of 3 the same way. The `live-cloud` suite passed 17 of
17 at the stack top.

Watch: a session whose model name equals a booted local simulator's name
is skipped as a local app.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 30, 2026
**Every runtime command acts on the device bound to this worktree, and
nothing else attached to the same dev server, and says which device it
acted on.**

This PR makes runtime reads and smoke skip an app on a simulator that
another worktree bound.

Stack: #202 → #203 → #204 → #206 → #217 → #218 → #207 → #200 (this is
3/8).

## Problem

A booted simulator that another worktree bound loads its JavaScript from
`127.0.0.1:8081`. When this worktree's dev server takes port 8081, that
app connects to it, and runtime reads read it as this worktree's app
(DF22–DF24, root cause DF29). In worktree `mapfilter`, bound to an
Android EAS session, `runtime:tree --json` returned `bundle.platform:
"ios"`. It read worktree `legend`'s simulator, with old JavaScript.

## Fix

A target is another worktree's app when every booted simulator with its
device name has a udid that only other worktrees bind with a live lease.
When the simulator list cannot be read, a target whose name only other
worktrees' bindings hold is skipped. Runtime reads, the debugger client
and smoke skip that app, with or without a platform flag, and name the
device and the worktree on stderr. `runtime:reload` lists it in
`skippedApps` and does not check its platform's bundle. A released or
expired binding does not count. smoke names the skipped apps once per
run, in its report and on stderr. A runtime read whose list empties
after the preflight refuses like the preflight: `NO_APP_CONNECTED`, exit
22. An `--android` run does not ask `simctl` because another worktree
holds an iOS lease. The no-app refusals live in `scopeRefusal.ts`, so
the debugger client refuses the same way without importing the
preflight.

Red test: targetSelection-test "skips a target with a foreign binding's
name when the simulator list could not be read". Also: e2e
runtime-target-selection-test "runtime:eval skips a renamed foreign
simulator and names its worktree".

Live check: run at the stack top only, not at this layer. The local iOS
simulator and EAS session runs (#207, #203) go through this layer's
binding lookup, but no app from another worktree was attached, so this
layer's claim rests on its red tests.

Watch: at this layer, a broadcast reload still reaches the skipped app.
#207 stops that.

Not in this PR: one `EmptyScopeReason` model for the refusal texts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 30, 2026
…bound to it (#206)

**Every runtime command acts on the device bound to this worktree, and
nothing else attached to the same dev server, and says which device it
acted on.**

This PR makes the device bound to this worktree the scope of every
runtime read and of smoke, with or without a platform flag.

Stack: #202 → #203 → #204 → #206 → #217 → #218 → #207 → #200 (this is
4/8).

## Problem

N2-33: a worktree bound to an iOS EAS session ran `runtime:tree`, which
read a local `iPhone 17 Pro` app with the same bundle id and named the
session as the device it read. N2-30: on the same kind of worktree,
`runtime:tree --ios` refused with "No app connected" while the session's
app was connected.

## Fix

The scope is this worktree's local binding on the run's platform while
its lease is live, else the one live cloud binding, else the flags. A
shut-down bound simulator has no app, so a read refuses and names it,
and smoke boots it. On Android, the run reads the app whose device name
carries the bound serial's model, in the spelling `adb devices -l` uses
(`Pixel 9` is `model:Pixel_9`). With two devices of that model, only the
one that alone reverses the port is read. An EAS Simulator Android
session skips an app on an attached emulator of its model. A wait asks
`adb reverse` again for a changed list, so a twin that attaches mid-run
cannot authorize a read. One function, `silentTool`, names the device
tool that gave no answer, for placement and for the wait's retry. A
command with no dev server refuses `NO_DEV_SERVER` before it reads a
binding; an unreadable EAS binding refuses `NO_BOUND_DEVICE`.
`AGENT_CLI_NO_DEVICE=1` skips only this machine's bindings, so an EAS
session still scopes the run. `runtime:reload --json` names the scope
under `scope`: platform, how it was chosen, whether it is bound, and the
device's backend, id and name.

Red tests: targetPlatform-test "matches %s to the attached Android
device adb names model:Pixel_9"; e2e runtime-bound-selection-test "eval
with android-cloud, flag none, reads androidSession";
targetSelection-test "classifyTargets on a bound phone whose model adb
spells differently". Also: e2e "runtime:tree refuses with
NO_APP_CONNECTED and does not name the session as read".

Live check (adb, on an API 29 emulator whose `Build.MODEL` is `Android
SDK built for arm64`): `adb devices -l` printed
`model:Android_SDK_built_for_arm64`, and the match accepted the React
Native name `Android SDK built for arm64 - 10 - API 29`. `adb reverse
--list` printed `host-14 tcp:8197 tcp:8197`; the parser read `tcp:8197`
/ `tcp:8197`. At the stack top, `runtime:reload --android` read the
bound emulator by that model, with an EAS Android app on the same dev
server skipped, on 3 of 3 runs.

Watch: no physical phone was checked.

Not in this PR: one device type for `SelectedDevice` and
`NavigateDevice`; one `EmptyScopeReason` model for the refusal texts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 30, 2026
…217)

**Every runtime command acts on the device bound to this worktree, and
nothing else attached to the same dev server, and says which device it
acted on.**

This PR moves the reload broadcast into its own module, so the change in
#207 to what a reload reaches reads as a behavior diff.

Stack: #202 → #203 → #204 → #206 → #217 → #218 → #207 → #200 (this is
5/8).

## Problem

The command-socket rung (the dev server's reload broadcast and the
peer-churn wait after it) is inside `src/runtime/reload/reloadAsync.ts`.
That file also holds the reload policy, the evidence wait and the
relaunch. #207 changes this rung. A move inside the same diff would hide
that change.

## Fix

A pure move, with no change in behavior. `reloadOverDevServerAsync`, the
`CommandSocketChurn` type, `PEER_CHURN_TIMEOUT_MS`,
`PEER_POLL_INTERVAL_MS` and the private `waitForPeerChurnAsync` move to
the new `src/runtime/reload/rungOne.ts`. The two constants are exported
because `reloadAsync.ts` still reads them. `reloadAsync.ts`,
`smoke/smokeAsync.ts` and `reloadAsync-test.ts` import them from
`rungOne.ts`. The `reloadAsync.ts` code-line cap goes down to 922.

Red test: none, because the move changes no behavior. The unit suite
passes with only import changes.

Live check: run at the stack top only. This layer moves code and changes
no behavior. At the top, rung 1 broadcast on a local iOS simulator and
was proved by `message-socket-peers` on 3 of 3 reloads.

Watch: `reloadAsync.ts` and `rungOne.ts` share the two churn constants
until the next split.

Not in this PR: the split of `reloadAsync.ts` into evidence, relaunch
and report modules. #207 starts it with `evidence.ts`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 30, 2026
…ext (#218)

**Every runtime command acts on the device bound to this worktree, and
nothing else attached to the same dev server, and says which device it
acted on.**

This PR gives #207 a proof, per debugger target, that a reload replaced
that app's JavaScript context.

Stack: #202 → #203 → #204 → #206 → #217 → #218 → #207 → #200 (this is
6/8).

## Problem

A `Page.reload` keeps the target's page id, so a fresh target cannot
prove it. On main, nothing proves that one target's context was
replaced. An acknowledged `Page.reload` does not show that the app
reloaded.

## Fix

`jsContextReplacedAsync` sets a global with a per-check name, takes the
action, and reads the global on the same target id until it is gone. One
debugger session owns the marker, the action, the reads and the cleanup.
A context that cannot keep the marker is unmarkable, and the action is
not taken. An action that the app answers with a CDP error is thrown. An
action whose answer is lost may have run, so the marker is read anyway.
Each phase has its own bound, and an absolute deadline bounds the whole
proof. The open handshake counts against a request's budget, and a
request that times out names the whole budget the caller gave. The phase
table is in llp/0005 §What proves a reload.

Red tests: contextReplaced-test "does not mistake another concurrent
check for a replaced context"; "the proof of a Page.reload" rows "reads
the marker when the socket drops before Page.reload answers" and "does
not prove a reload whose answer was lost and whose context stayed";
cdpClient-test "should name the caller's budget when the socket opened
late".

Live check: at the stack top, on an SDK 58.0.21 development build on a
local iOS 27.0 simulator, with a second app on another simulator so that
the run skips one app: `runtime:reload --ios --json` reported
`verifiedBy: debugger-context-replaced` (`outcome: context-replaced`) on
4 of 4 reloads, exit 0. On an EAS iOS session (Expo Go, v2 tunnel) with
a local simulator app on the same dev server: `runtime:reload --eas`
skipped the local app as `other-device` and proved 2 of 2 reloads by
`debugger-context-replaced`; `runtime:reload --ios` skipped the cloud
app and proved 3 of 3 the same way. On Android at the stack top, with
Expo Go on a local API 29 emulator and on an EAS Android session on one
dev server: `runtime:reload --android` skipped the cloud app and proved
3 of 3 by `debugger-context-replaced`; `runtime:reload --android --eas`
skipped the local app and proved 3 of 3 the same way.

Watch: on Android the proof was seen with Expo Go only, not yet with a
development build.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 30, 2026
…skips an app (#207)

**Every runtime command acts on the device bound to this worktree, and
nothing else attached to the same dev server, and says which device it
acted on.**

This PR makes a reload reach only this worktree's apps when the run
skips an app, and prove the reload with this worktree's apps only.

Stack: #202 → #203 → #204 → #206 → #217 → #218 → #207 → #200 (this is
7/8).

## Problem

The dev server sends its reload broadcast to every app on its port.
N2-17: a worktree bound to an iOS EAS session ran `runtime:reload`. It
printed "Skipped 1 app on local simulators … iPhone 17 Pro", then
"Reloaded yes · via the dev server's client command socket … 2
client(s)". The broadcast also reloaded the skipped app.

## Fix

Rung 1 is decided on one target list read after the bundle gate. While
the scope skips an app, rung 1 sends no broadcast. It sends CDP
`Page.reload` to each own target and proves each with the context
marker, under one shared deadline. When nothing is skipped, rung 1 stays
the broadcast, and the attempt says when it also reached an app outside
the scope. Peer churn beside an app outside the scope makes the attempt
`ok: false, delivered: true`: the reload then needs a fresh own target
or a bundle. Nothing of an app outside the scope proves the reload. The
failure text says whether nothing was watched, the churn was discounted,
or the watched observations did not happen. When no own app could keep
the marker, it says no `Page.reload` was sent. It names `--eas` and the
tunnel only on a cloud run. `Page.reload` has 8 s to prove itself, and
the first proof stops the other checks (`stopped`). A `Page.reload`
whose answer was lost is not delivered. When the read before rung 1
fails, the run keeps its first placement and sends nothing (mechanism
`none`). After a relaunch that nothing can prove, local or cloud, the
run exits 22 at once instead of waiting out `--timeout`. CI runs the
reload cases that need a local simulator on the macOS job.

Red tests: rungOne-test "a broadcast beside an app nothing places: no
bundle proves it"; e2e runtime-target-selection-test "sends Page.reload
to its own app only, and a page of the skipped app proves nothing
($pageReload)", "sends no Page.reload when its own app cannot keep the
marker" and "runtime:reload does not take another platform's churn as
its proof".

Live check (before the 8 s window and the relaunch exit; not rerun
since): at the stack top, on an SDK 58 development build on a local iOS
simulator. With a second app on another simulator, the run skipped it as
`other-device`, sent `Page.reload` to its own app only, and proved 4 of
4 reloads by `debugger-context-replaced`. With one app, it broadcast and
proved 3 of 3 by `message-socket-peers`. On an EAS iOS session (Expo Go,
v2 tunnel) with a local simulator app on the same dev server:
`runtime:reload --eas` skipped the local app as `other-device` and
proved 2 of 2 reloads by `debugger-context-replaced`; `runtime:reload
--ios` skipped the cloud app and proved 3 of 3 the same way.

Watch: when rung 1 broadcasts, an app that attaches after the last
target list also gets the broadcast.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
vonovak added a commit that referenced this pull request Oct 30, 2026
**Every runtime command acts on the device bound to this worktree, and
nothing else attached to the same dev server, and says which device it
acted on.**

This PR makes `navigate` decide "attached" from the device it opens.

Stack: #202 → #203 → #204 → #206 → #217 → #218 → #207 → #200 (this is
8/8).

## Problem

`navigate` counted the attached apps of every platform. N3-25 (also
DF22): after `dev --android --eas`, with only a local `iPhone 17 Pro`
app on port 8081, `navigate "/street/…"` with no flag counted the iOS
app as attached. It skipped the launcher, opened only the route link,
and exited 22 after 120 s. On Android, the stuck-app recovery could also
stop an app that another device ran on the same dev server.

## Fix

`navigate` validates the route, resolves the device, and then reads the
attached apps of that device's scope to decide the app kind, the route
URL and the launcher. One reader serves the launcher wait and the route
wait. It reads the device facts again only for a changed list, within
the budget of the wait that polls. So a cloud app that attaches after
the accepted Expo Go dialog counts. When an Android twin of the bound
device is attached, `navigate` reads `adb reverse` before the launcher
decision and again after this run's own `adb reverse`. When nothing
attached on Android, the recovery stops `--app-id`, else the app
config's app, else Expo Go. smoke hands its device to the route open, so
the open and the waits are about one device. `--print-url` reads the
scope as `navigate` does, `adb reverse` included, once the dev server
lists an app. The recovery stops Expo Go when this run opened it, and
says the app was stopped only when `pidof` saw it running.

The first commit is a pure move of route URL resolution to
`src/navigate/routeResolution.ts`. The second commit holds the behavior.

Red tests: navigate-target-selection-test "no flag, bound Android
emulator, only an iOS app and another model's Android app attached";
navigateAsync-test "asks adb for the device list once while the attach
wait polls an unchanged list"; e2e cloud-simulator-test "counts the app
that attaches after the dialog is accepted";
navigate-target-selection-test adds three Android twin rows, one of them
"no flag, bound Android emulator beside a twin, neither reverses the
port before this run".

Live check: the `live-cloud` iOS suite passed 17 of 17 at the stack top
on an EAS session: `navigate / --eas` exit 0, attached after 3 ms. Plain
main gives the same results.

Watch: after a wait's budget ends, its later polls keep the last device
facts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants