Skip to content

feat(cli): open --wait, flag suggestions, and refusals that name what to run - #2665

Merged
thymikee merged 7 commits into
mainfrom
t3code/fix-device-cli-feedback
Sep 19, 2026
Merged

thymikee merged 7 commits into
mainfrom
t3code/fix-device-cli-feedback

Conversation

@thymikee

@thymikee thymikee commented Sep 18, 2026

Copy link
Copy Markdown
Member

Summary

Three CLI feedback items, 28 files, across five rebased commits.

Guessed flags now name the real one. --output/--path were refused with Unknown flag. Suggestions are filtered by what the command in scope accepts, so nothing is pushed that the command would reject:

$ agent-device screenshot --output /tmp/x.png
Unknown flag: --output. Did you mean --out?
$ agent-device trace start --path ./log
Unknown flag: --path            # --out means nothing here, so no suggestion

open --wait <ms> blocks for a device another session holds, then either opens it or fails with DEVICE_IN_USE naming the owning session address:

$ agent-device open Demo --platform android --wait 60000

Acceptable budgets range from 100 to 120000 ms. Their bounds are declared once and enforced across CLI, MCP, Node, and raw daemon input. Only session contention is waited for; a device claim owned by another workspace’s daemon remains immediately non-retriable and returns its recovery command.

The wait runs before the request takes the device execution lock, because close, record stop and every other operation that could free the device need that same lock. A first attempt that waited inside the lock stalled the competing close for the whole budget — that inversion is the reason for the placement. Once the device appears free, the request queues on its locks and rechecks while holding them. If another open takes the device in that interval, the losing request continues waiting with the remainder of its own budget rather than refusing. Multiple opens can therefore wait for one device until their budgets are spent, though arrival order is not guaranteed. Spent budget is recorded only when a holder is observed and the budget is expired. The budget widens the timeout envelope instead of eating into it.

Non-macOS Apple refusal now carries supportedOn, hostOs, retriable: false and names the Android commands plus the remote-Mac route, instead of reading like a missing install.

Validation

Tested at 46334729f915ae2446857747947c9e5d763d23e9: rebased onto current main, pnpm check:affected --run passed (40 runnable checks), plus pnpm format, pnpm lint, pnpm typecheck, pnpm check:layering, pnpm check:fallow, and pnpm check:mcp-metadata. The focused daemon/session/handler Vitest subset passed 400 files and 2766 tests.

The final review round tightened the seams: the budget is parsed once before device resolution and passed to the wait, a missing declaration now fails closed, and describeOpenWaitForRefusal checks command === 'open' directly rather than inferring eligibility from the presence of waitMs.

The race task in request-execution-scope-open-device-wait.test.ts now refuses through the same session-store fact production uses. If runLocked is forced to bypass runWhenDeviceIsUnheld, it loses because a message is returned; without the production fallback at all, it begins another session and consequently says so. Another scope test proves out-of-range budgets get INVALID_ARGS before resolveTargetDevice is called.

Live on Pixel 7 CI (emulator-5554) from this exact build, same daemon and app:

  • waiter-a and waiter-b started behind holder. holder let go; both then observed device available.
  • waiter-a first bound target session waiter-a.
  • waiter-b continued to queue, logging held by session "waiter-a" at the 1284ms and subsequent checks, not past its budget proxy.
  • close waiter-a resulted waiter-b opened; no session or daemon session left.
  • expected elapsed values from device tests: Waited 1001ms for this device and it stayed busy. with owner and close/reuse guidance, and no repeated --wait instruction.
  • --wait 50 was CLI-rejected; their proxied Node waitMs: 50 and waitMs: 5000000 likewise failed, and their request fetches now fail before Target Device Resolution because their budgets themselves cannot be spent.

Branch CI is green at 46334729f915ae2446857747947c9e5d763d23e9, with all required platform/CI checks, Coverage, Repo Guards, Compatibility & Provenance, Typecheck & Package, Integration Tests, command docs, previews, bundle size, and both CodeQL scan lanes passing.

@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-19 10:09 UTC

@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.60 MB 4.61 MB +5.5 kB
Package (unpacked) 4.60 MB 4.61 MB +5.5 kB
Package (download) 1.37 MB 1.37 MB +2.0 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 29.6 ms 29.5 ms -0.1 ms
CLI --help 80.0 ms 85.3 ms +5.2 ms

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed da2d5f8. Placing the wait before the device lock looks right, but there seems to be a race after the wait ends, so I can't call this ready yet.

waitForOpenDeviceContention returns as soon as no session holds the device (https://github.com/callstack/agent-device/blob/da2d5f8/src/daemon/open-device-contention-wait.ts#L64), but nothing reserves the device until request-execution-scope.ts takes the lock. If two opens wait for the same device, both can see it free on the same poll. The second one then hits the locked conflict check in session-open-execution.ts and fails with DEVICE_IN_USE and "Waited Xms for this device and it stayed busy", even with most of its budget left. The same thing can happen when the first look finds the device free and another session takes it right after: the hint then tells a caller who already passed --wait to run the same open with --wait. Parallel callers waiting for one device is the main use case, so they would give up early on a device they could get. Could the locked conflict check, when another session holds the device and budget remains, release the request locks and wait again for the rest of the budget? At minimum, record the spend only when the loop ends on its deadline, and word the locked refusal as "taken after the wait ended", not "stayed busy". A test through createRequestExecutionScope with two waiters would pin this.

Not blocking: the wait tests call waitForOpenDeviceContention directly with a stubbed resolveDevice, so moving the wait back inside the lock would keep them green. A scope-level test (a holder, a waiting open, and a concurrent close that takes the device lock mid-wait) would protect the ordering this PR is built on, and the cancel test could assert the canceled error code instead of a bare .rejects.toThrow().

Would a smaller wait do? Resolve the target device once, through the same helper resolveRequestExecutionLockKeys uses, then poll only the in-memory session store. That removes resolveAdvisoryOpenDevice, and with the retry in the locked check, internal.openDeviceWait only needs the final elapsed time. The retry needs createRequestExecutionScope to release and re-enter the wait, which it can't do today. If you rejected this shape, what would have to change first?

One thing I could not confirm from the code: does the Node and MCP waitMs input enforce the same 100–120000 bounds as the CLI flag?

All checks pass and there are no conflicts. The next step before merge is closing the race between the end of the wait and the device lock, with a test that shows a second waiter opens, or refuses only after its full budget.

@thymikee

Copy link
Copy Markdown
Member Author

Addressed in ffc2599.

The race is closed the way you framed it. createRequestExecutionScope now builds an OpenDeviceWait from its own execution-lock plan and runLocked runs the open through runWhenDeviceIsUnheld: the look inside acquireLocks is authoritative, because another open can only put a session on a device by writing the store, and it writes that store under the same device lock. A holder visible there with budget left means the open returns from the locked task — releasing its locks — and waits for the rest of its budget. open --wait now spends the whole budget before it refuses, and a second waiter opens once the winner hands the device back.

With the retry in place the two minimums fell out rather than needing their own wording: the spend is recorded only at that under-lock look, when a holder is visible and the deadline is gone, so "Waited Xms for this device and it stayed busy" is only ever said by an open that really spent the budget; and a refusal whose wait vanished between the look and the lock now reports no spend at all. --wait is also no longer offered to a caller that arrived carrying it — describeOpenWaitForRefusal gates it on the request, not on whether a spend was recorded.

Tests: request-execution-scope-open-device-wait.test.ts drives two real scopes over one lock map (holder releases, first open binds, second re-waits and still opens inside 1s) and a second case starts the waiting open's locked work first, then a close that must get through while the open is queueing. The cancel case asserts isRequestCanceledError rather than a bare throw.

The smaller wait, taken. resolveRequestExecutionLockPlan returns { keys, deviceId } — the device the plan reserves is the device the wait polls for, so resolveAdvisoryOpenDevice is gone and the loop reads only the in-memory store. internal.openDeviceWait still carries just the final elapsed time. Nothing had to change first; the release-and-re-enter you asked about is runWhenDeviceIsUnheld taking acquireLocks from the scope.

Bounds: MCP yes, Node no — fixed on the daemon. optionField('waitMs') derives the MCP input from the declaration, so the tool surface already refused waitMs: 1 and 10000000. The Node client copies waitMs into flags with no bound, and nothing on the wire checks per-key values, so readOpenWaitBudgetMs — the only daemon reader — now validates through the same readOptionalInteger the swipe readers use, against bounds read from the declaration. open({ waitMs: 1 }) gets INVALID_ARGS: Expected waitMs to be at least 100. The test reads the bounds from the declaration so it cannot drift from the CLI. The timeout envelope still widens by the raw flag, but an out-of-range budget is refused before device work, so it can no longer buy a long request slot.

pnpm check:affected --run, lint, typecheck, mcp-metadata and the 247 daemon/session unit files pass at ffc2599. The live check still needed: the two-workspace queue on emulator-5554 (second waiter opens instead of refusing).

@thymikee

thymikee commented Sep 18, 2026

Copy link
Copy Markdown
Member Author

Review fixes are now at f4f412905bdeeb7f7ae8a1ff055ceacf13f1d59f.

While proving the new bounds on the live Node client, I found a sequencing hole: an out-of-range waitMs reached device resolution before the declared-bound check when the lock-plan lookup could not resolve a deviceId. The budget is now validated before any device work, so CLI, MCP, Node, and raw-wire paths receive the same INVALID_ARGS at the same bounds.

Live results and current gate status are recorded in the PR body. The emulator has no sessions left from this validation.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed f4f4129, following up on da2d5f8 (#2665 (comment)). The race fix itself looks right: the look under the device lock now decides, an open that loses the race releases its locks and re-waits on its remaining budget, cancel still stops the re-wait, and the live runs reported in the PR body cover the handoff, the lost race, budget expiry and the bounds. One thing is left: the new scope-level test does not prove the fix.

In the race test (https://github.com/callstack/agent-device/blob/f4f4129/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts#L76), the second opener's task sets its session without checking whether another session holds the device, so it can never refuse. If runLocked went back to plain requestExecutionLocks.run and dropped runWhenDeviceIsUnheld, the second task would still run right after the first, and the order, 'second-opened' and the under-1000ms timing would all still pass. Could the task double refuse with DEVICE_IN_USE when sessionStore.findByDevice(device) finds another session, the way findNewSessionDeviceConflict does, or assert that the second task starts only after first-opener is deleted at 400ms? Either should turn the test red when runLocked skips runWhenDeviceIsUnheld.

Not blocking: resolveRequestExecutionLockPlan still resolves the target device before beginOpenDeviceWait reads the budget (https://github.com/callstack/agent-device/blob/f4f4129/src/daemon/request-execution-scope.ts#L179), so an out-of-range waitMs does one inventory lookup before it gets INVALID_ARGS; reordering the two calls or softening the "before any device work" note would both do.

All checks pass at f4f4129. The PR now conflicts with main and needs a rebase. Before merge, the race test needs to fail without runWhenDeviceIsUnheld, and the conflict needs to be resolved.

--output and --path are the spellings an agent reaches for before reading the synopsis, and
the refusal only said "Unknown flag". Suggestions now come from the live flag registry
filtered by what the command in scope accepts, so a guess is never answered with a flag that
command would refuse: "trace start --path" keeps the bare refusal, while
"screenshot --output" is pointed at --out.
A device another workspace's session is holding refused at once, leaving the caller to poll
by hand, and the foreign-workspace refusal named a session its `--session` value could not
reach.

`open --wait <ms>` spends a budget on that contention before the request takes the device's
execution lock, which is the whole design: `close`, `record stop` and every other operation
that could free the device need the same lock, so an open that waited while holding it would
have blocked its own recovery. Verified on a live emulator — a waiting open now lets a
competing `close` return in 0.1s and opens the device itself once the holder is gone; the
first attempt at a daemon-side wait inside the lock stalled that close for the full budget.

The refusal that ends a spent budget names the owning session address, says the wait happened
and does not offer it again, while a refusal from a command that cannot wait never mentions
the flag. The budget extends the command's timeout envelope rather than eating into it, so a
long wait cannot end in a client-side daemon reset.
"Apple tools are only available on macOS" is true and useless to a caller on Linux that just
wants its app driven: it reads as a missing install, and `retriable` was unset so replay
could retry it. The refusal now carries `supportedOn`, the observed `hostOs`,
`retriable: false`, and a hint naming the Android commands for the same workflow plus the
remote-Mac-daemon route.
An `--wait` budget ended as soon as no session held the device, but nothing
reserved the device until the request took its execution lock, so two waiters
could both see one free device and the loser refused with most of its budget
left. The open now looks at the device again under its own locks, hands them
back when another session took it in that window, and waits for what remains of
its budget. The recorded spend moves to that same look, so only a budget that
ran out with a holder visible reports a spent wait, and a refusal stops offering
`--wait` to a caller that arrived carrying it.

The target device is resolved once, as part of the request's execution-lock
plan, and contention is polled in the session store alone. A budget that reaches
the daemon anywhere but through the CLI is now checked against the bounds the
option declares, which the Node client never enforced.
Reading the budget after the target device was resolved left a hole: an open
whose device could not be resolved never reached the bounds check, so a Node
client or a raw wire request carrying `waitMs: 5000000` passed the check the CLI
parser would have applied and went looking for a device with a budget it could
not spend. The budget is now the first thing the wait reads.
The race test's open task bound the device unconditionally, so adding an `open --wait` budget to
the current execution locks would allow all old checks to be demonstrated. The task refuses with
the same fact the real open does. Out-of-range `waitMs` budgets fail before any device work.
@thymikee
thymikee force-pushed the t3code/fix-device-cli-feedback branch from f4f4129 to 8cbf480 Compare September 18, 2026 16:03
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto current main and addressed f4f4129\u2019s outstanding notes at 8cbf480b23fddae43e66a27ce749e76b77e0c7aa.

The race scope task now checks the same session-store fact as production and returns a refusal when it finds a current owner. I temporarily bypassed runWhenDeviceIsUnheld to confirm the test goes red at opened:second-opener while the fallback returns refused:first-opener, then restored the operation. A second scope test pins a declared-bound violation to INVALID_ARGS before the mocked resolveTargetDevice call count changes.

Incremental revalidation for exact current build caused both concurrent waiters to poll holder free. Winner waiter-a bound; waiter-b continued its budget while observing its new holder and then opened after it closed. Device now clean and the final validation is in the body.

@thymikee

Copy link
Copy Markdown
Member Author

Update: every listed CI check passes at 8cbf480b, and GitHub reports the PR clean against main. The PR body now records rebase, test proof, revalidated device queue behavior, and check status.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed 8cbf480, a follow-up to f4f4129 (#2665 (comment)). Both open points are fixed. The race test now fails if runLocked skips runWhenDeviceIsUnheld: the second task checks sessionStore.findByDevice like production does, so it would return refused:first-opener instead of opened:second-opener. An out-of-range waitMs now fails with INVALID_ARGS before resolveTargetDevice runs, and the new test pins that call count. The rebase kept the lock release, the re-wait on the remaining budget and the cancel check; the range-diff changes only import lines.

Not blocking: after the rebase, commit 2 no longer imports DaemonFailureResponse, which session-open-execution.ts still uses, and the import only comes back in 8cbf480 (https://github.com/callstack/agent-device/blob/8cbf480/src/daemon/session-lifecycle/internal/session-open-execution.ts#L70). So commits 2-5 do not type-check on their own; squashing on merge or folding the fix into commit 2 keeps bisect clean.

I traced the mutation result by reading the code and did not run the tests. The new bounds test covers only the upper bound; the lower bound goes through the same readOptionalInteger check.

All checks pass at 8cbf480, and there are no conflicts. I have nothing left that blocks this.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 18, 2026
The request scope now parses the `waitMs` option once and supplies the resulting budget to the
wait, instead of validating it before the device lookup and reading it again afterward. The reader
fails closed when a caller supplies a budget but its option declaration has gone missing. The
refusal helper checks the command directly, so reuse outside the open path cannot offer `--wait`
to an interaction.
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed 4633472. The new commit only changes where the open wait budget is read. The request scope parses waitMs once, still before any device lookup, and passes it to the wait, so an unspendable budget is still refused before device work. Re-wait on retake, cancellation and the contention guard are unchanged. The refusal now offers --wait only for open, and the new test fails on the earlier head.

Not blocking: the INTERNAL_ERROR branch for a missing waitMs declaration (https://github.com/callstack/agent-device/blob/4633472/src/daemon/open-device-contention-wait.ts#L29) can fire only if the registry entry is deleted, and no test covers it; keeping or dropping it is fine.

Coverage now passes and Smoke Tests is still running, with no failures. The commit touches request-execution-scope.ts, which every locked request passes through, but it only moves a read on that path, so a failure there is unlikely to come from this change. There are no conflicts. This stays ready for a human review.

@thymikee

thymikee commented Sep 19, 2026

Copy link
Copy Markdown
Member Author

Applied the three review-triage edits at 46334729f915ae2446857747947c9e5d763d23e9:

  • parse waitMs once in the request scope and pass its budget to beginOpenDeviceWait;
  • fail closed when a request carries a budget but the option declaration is unavailable;
  • gate offersDeviceWait on req.command === 'open', with a tap-request regression assertion.

The PR body no longer claims fair queueing: concurrent waits re-queue on their remaining budgets, but arrival order is not guaranteed. Full CI is green on the final head; the event-driven drain was deliberately left out of scope.

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

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant