14 Commits

Author SHA1 Message Date
Michał Pierzchała 34e8cbb7a2 docs+ux: make device ownership discoverable end to end (#2165)
* docs+ux: make device ownership discoverable end to end

Complete the #1320 agent experience so 'busy? -> inspect -> choose or
release' is discoverable from every surface an agent actually reads:

- devices now projects the blocking claim owner per row (claimedBy with
  session and workspace, observe-policy projection; provably dead owners are
  excluded because the next open replaces them automatically), so an agent
  told a device is busy can pick a free one from the same listing.
- help debugging gains a 'Device busy and ownership' section separating the
  two DEVICE_IN_USE flavors and their exact recoveries.
- AGENTS.md documents both flavors; docs/agents/device-verification.md
  retires the last ps/kill recovery guidance in favor of device status,
  daemon stop --state-dir, and device release --stale (Stage 5 of #1320).
- ADR-0010 no longer calls DEVICE_IN_USE 'the only retriable code' without
  naming the claim path's non-retriable override.
- The rendered cross-worktree claim error gains a help-conformance quiz case
  binding (sample-output-device-claim-inspects-owner).
- README points at device status / device release --stale.

Part of #1320.

* fix: key ownership projection by canonical device identity end to end

Review findings on #2165:

- blockingClaimOwnersByDevice keyed claims and inventory rows by bare
  device.id, so a live Android claim could project claimedBy onto an
  unrelated same-id Apple/Harmony/Vega row, with scan order picking the
  displayed owner. Both sides now use the canonical local device key
  (claim.deviceKey against canonicalLocalDeviceKey of the row's claim
  identity). The cross-family same-id regression was observed red against
  the bare-id keying.
- The projection is now asserted across every hop the PR promises: client
  normalization preserves well-formed claimedBy and drops malformed ones,
  and the devices CLI formatter carries it through JSON data and renders
  the text line (MCP shares the same serialization).
2026-08-31 14:32:29 +02:00
Michał Pierzchała 6a8beb653e feat(mcp): compact server instructions in both eras + MCP-only help tool (#1839)
* feat(mcp): compact server instructions in both eras + MCP-only help tool (#1833)

MCP-only clients got no workflow guidance: server/discover carried two
sentences, legacy initialize carried nothing, and the CLI guides
(agent-device --help, help <topic>) were unreachable over MCP.

- MCP_SERVER_INSTRUCTIONS: one MCP-phrased workflow card (<2 KB, the
  Claude Code truncation limit) returned by server/discover and legacy
  initialize alike.
- help tool, router-owned (not a command descriptor): no topic -> the CLI
  decision card; topic -> agent-device help <topic|command> text, prefixed
  with the one-line CLI->tool-property mapping; unknown topic -> isError
  listing the topics. listCommandTools() stays descriptor-only for the AI
  SDK; the router composes descriptors + help.
- Move src/cli/parser/cli-help{,-overview}.ts to src/cli-schema/ so
  src/mcp (rank 3) can import the renderers without a layering back-edge
  into src/cli (rank 6).

* fix(mcp): name terminal-only commands in help guides; colocate cli-help tests with their sources

- The MCP guide preamble claimed every `agent-device <command>` line is a
  tool of that name; `help web` tells the reader to run `web setup` /
  `web doctor` and no `web` tool exists. The preamble now lists the exact
  CLI-only set (listCliCommandNames minus listMcpExposedCommandNames) —
  derived, not scanned out of prose where `device`/`web` are ordinary
  words. Regression: help web names `web` as terminal-only, and the listed
  set equals the registry difference.
- cli-help-*.test.ts move from src/cli/parser/__tests__ to src/cli-schema/
  to mirror the moved sources.

* perf(mcp): tighten the guide card, tool description, and preamble

Instructions card 1572 -> 1378 bytes (paid every session), tool
description and preamble trimmed, HELP_TOOL built once as a const.
Bundle delta vs main 3189 -> 2715 bytes; the remainder is the guide text
itself, which the bundle carried in no MCP-phrased form before.
2026-08-18 17:48:36 +02:00
Michał Pierzchała cdc754e6ed perf: speed up iOS agent recovery and streamline CLI guidance (#1700)
* Avoid interactive children in parent taps

* docs: streamline no-skill CLI help

* perf: recover faster from sparse iOS trees

* fix: preserve selector context for blocked parent taps

* fix: preserve coordinate text-entry focus

* fix: preserve thin parent touch targets

* fix: fail closed for unscoped iOS typing

* test: isolate replay lock fixture

* test: share node integration process
2026-08-10 20:43:01 +02:00
Michał Pierzchała 18291ba8e2 perf: collapse app-driving startup turns (#1693)
* perf: collapse app-driving startup turns

* fix: align foreground open guidance
2026-08-09 10:10:11 +02:00
Michał Pierzchała 9c25bc66f4 docs(cli): advertise open --foreground and snapshot --actions in the workflow card (#1682)
* docs(cli): advertise open --foreground and snapshot --actions in the workflow card

open --foreground (#1670/#1671) and snapshot -i --actions (#1665) shipped
with no mention in the compact `help workflow` card, so a planning model
never discovers either. Add one terse line each: the foreground fast-path
in Bootstrap, and the merged-element custom-action guidance in Validation
and evidence. Stays under the 9,000-byte compact-card budget (8493 -> 8908
bytes).

Adds two help-conformance bench cases per the repo's changed-guidance rule:
foreground-attach-single-sim (correct plan starts with `open --foreground`
in an unambiguous single-sim scenario, fail-closed alternative forbidden)
and merged-card-actions-not-directly-invokable (a merged Bluesky-style feed
card's actions list is evidence, not a selector). Both use a real pinned
sample rebuilt through the production snapshot renderer.

* fix(scripts): accept flag order in the foreground-attach conformance matcher

Flag order after `open` isn't semantically meaningful (`open --platform ios
--foreground` is exactly as correct as `open --foreground --platform ios`),
but startsWithForegroundOpen required --foreground to be the literal next
token after `open`. Rescoring the completed repeat=3 bench report shows this
docked codex:gpt-5.4-mini on all 3 trials even though its plan was
config-order noise, not a real deviation -- the no-positional/no-device
guarantee already comes from the forbidden checks. Loosened to require
--foreground anywhere on the open line; foreground-attach-single-sim now
scores 54/54 across both runners.

* fix: close workflow help conformance gaps
2026-08-08 10:35:12 +02:00
Michał Pierzchała e6b4fa2810 fix: isolate concurrent remote connections (#1675)
* fix: isolate concurrent remote connections

* refactor: harden remote connection state

* fix(cli): scope every emitted connect command to its own session

`scopeNextSteps` only reached `ConnectReadiness.nextSteps`, so two
command-bearing outputs still shipped unscoped:

- `providerArtifactNotes()` emitted `agent-device artifacts --json` as a
  prose note, which never passes through that helper.
- `buildDeferredRuntimeNotice()` emitted `agent-device metro prepare
  --remote-config <path>` independently in connection.ts.

On the shared-host concurrency path this branch fixes, following either
one resolves against the host-global active connection, so the artifacts
instruction can return another job's provider video and log URLs (#1659).

Both producers now take the connection state and format through one
exported `scopeCommand` helper, which is the single place a suggested
command is bound to its originating session. The metro config path is
shell-quoted alongside the session name.

Coverage: the BrowserStack route asserts human and JSON shapes carry one
`--session` per suggested command and that each emitted session resolves
back through `readRemoteConnectionState` to the connection that printed
it; `connection status` pins the scoped deferred-metro `nextStep`.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-08 07:54:43 +02:00
Michał Pierzchała a158434a9c feat(cli): compact workflow help card + version header (#1663)
* feat(cli): compact workflow help card + version header

Shrinks the per-task agent protocol tax of the help/skill surface.
`agent-device help workflow` drops from 41025 to 8466 bytes (-79%) by
moving depth into new `help scripting` (save-script, secret-safe
fills, batch JSON, replay divergence/repair) and `help gestures`
(multi-touch shapes/quirks) topics, and folding a few paragraphs into
topics that already owned the subject (help debugging,
help physical-device, help validate). Content is moved, not deleted.

Every `help <topic>` first line is now `agent-device <version> —
<topic>`, so the skill router reads the CLI version off the mandatory
first help read instead of a separate `agent-device --version` call.
SKILL.md is updated to do that and stays a thin router otherwise.

The compact card also gains two terse behavioral rules: chain
confident consecutive steps with `&&` (falling back to one command at
a time when uncertain), and confirm the requested end state is
actually visible on screen before declaring a task done.

help-conformance-bench (22 cases x 2 runners) improves after the
change: 29/44 -> 32/44 passing checks.

* fix(cli): review follow-ups on the compact workflow card (#1663)

Three fixes from PR review:

- Extend the help-conformance plan validator to split a command line
  on unquoted && and validate each chained segment independently, so
  a plan that follows the workflow card's "chain confident consecutive
  steps with &&" guidance is accepted instead of rejected as one
  shell-projection violation. && inside a quoted selector value (e.g.
  label="A && B") is not a chain boundary and does not split. Adds
  unit tests for the splitter and a chains-confident-consecutive-
  settle-steps conformance case. batch stays out of this: it is
  deliberately stop-only.

- Replace the literal @ref placeholder the compact card used in its
  own "snapshot -s @ref" example with a concrete ref
  (snapshot -s @e12 (the current concrete ref)), matching the same
  card's rule against placeholder targets. Reverts the test to demand
  the concrete shape.

- Give help scripting and help gestures real conformance cases
  instead of waivers: a secret-safe recorded-fill + publish case, and
  an Android transform-then-verify case whose exact verification text
  only appears in the gestures topic. Removes both waivers.

help-conformance-bench (25 cases x 2 runners, repeat=1) after these
fixes: two full runs landed at 32/50 and 33/50. That is on par with
the pre-change baseline (29/44) once the topic-untouched cases'
run-to-run swings are accounted for (confirmed noise: one case with
zero exposure to any change here flipped 10/10 -> 1/10 on a runner API
error, and another swung across all three post-fix runs). The new
scripting case now passes 8/8 for both runners; the new chaining case
correctly reports the model's choice not to chain as a soft signal,
not a validator failure.

* fix(cli): update session.test.ts help pointer for moved script-authoring content

* fix(cli): reject empty && chain operands in the plan validator (#1663)

splitOnUnquotedAnd() previously trimmed and filtered out empty
segments, so a plan with a leading (`&& press ...`), trailing
(`press ... &&`), or doubled (`a && && b`) operator passed
validPlanCommands even though a real shell rejects all three as a
syntax error. The validator would bless a plan that fails at
execution.

Empty segments are now surfaced as an `empty-chain-operand` issue
instead of being silently dropped. The quoted-&& non-split behavior
(label="A && B") is unchanged, and a normal single command with no
chain still parses identically to before.

Adds regression tests for all three empty-operand shapes plus the
quoted-&& case.
2026-08-07 13:26:48 +02:00
Michał Pierzchała 3937036e5e feat: support --settle on scroll and back (#1638) (#1650)
* feat: support --settle on scroll and back (#1638)

Scroll-then-observe and back-then-observe are legitimate agent pairs, but
the post-action observation registry never grew past the touch commands, so
`--settle` on either was rejected with INVALID_ARGS — burning a tool call
each in AppControlBench's bsky-16.

Both commands now carry the `settle` descriptor trait, and every surface
derives from it rather than a hand list: CLI allowed flags, MCP/SDK input
fields, the flag-sourced timeout envelope, and MCP ref-pinning. The CLI
flag/metadata helpers moved out of the interaction family into
post-action-observation-grammar.ts (back is a system command), and
SETTLE_REF_ISSUING_TOOLS became a derivation — a hand list would have
silently stopped pinning the new commands' refs.

settleAfterInteraction and the new settleObservationCommand are two entry
points over one engine: same loop, storage, hints, and diff bounds, with the
target-less path supplying its own baseline and no proximity point. The
daemon reaches that command through the runtime surface, never by importing
`commands/` (R2) — the same seam the touch handlers use for press/fill —
and generic-settle.ts is loaded through a lazy `await import` returning a
closure, so the interaction runtime subgraph stays out of this dispatcher's
static graph (a static edge folded ~18 files into the daemon-server type
cycle; R10 caught it).

Both of generic-settle's orderings are load-bearing and tested: the baseline
is frozen before dispatch (and before the Android dialog preflight), and the
observation runs after markDeferredInteractionOutcome so settle's first
capture folds in the #1542 stabilization rather than racing it. The ADR 0014
"a settled diff publishes refs" rule moved to settle-ref-issuance.ts, shared
by both routes.

One divergence is deliberate: scroll/back resolve no element, so the diff
baseline is the session's STORED pre-action tree — "settled tree vs the
last tree you observed" — not press's freshly resolved pre-action capture.

Both commands also switch to preserve-daemon on timeout, which changes the
non-settle path too: with --settle their dominant hang mode is now a wedged
accessibility bridge, and a timed-out capture must not reset the daemon and
lose every session (#1105). The reviewed-set gate records it.

Live-validated on an iOS 26.2 simulator (Settings): scroll --settle settled
in 1786ms with a +6/-6 diff carrying fresh refs; back --settle in 771ms with
+15/-6. Alternating cost runs, one call vs the pair it replaces:
scroll 2.9-3.0s vs 5.3-5.6s, back 3.1-3.2s vs 4.7-5.1s. Those include the
#1627 deep-capture extension.

* fix: render settled-diff refs paste-ready in CLI output

A settled diff activates a PARTIAL ref frame (ADR 0014), which admits only
the pinned `@eN~s<gen>` form of the refs it issued. The unchanged-interactive
tail already rendered that way, but the diff's own added lines rendered the
bare `@eN` embedded in the snapshot line — so a CLI caller who copied the
ref the diff just handed them got `plain_ref_requires_complete_frame` and had
to append the generation by hand.

Added lines now render pinned when the response carries `refsGeneration`,
exactly like the tail. Removed lines render verbatim: they name elements that
just left the screen, and `SettleDiffLine` never gives them a ref.

This is not new to scroll/back — press/click/fill/longpress had the same gap
since #1101. MCP was never affected: its ref-pin store rewrites plain refs on
the way in, which is why the model never sees a suffix.

Live: `scroll down --settle` now emits `+ @e14~s218078 [cell] "Game Center"`,
and `press @e14~s218078` copied straight out of that line taps successfully.

* test: record the pinned-diff-ref bytes in the output-economy baseline

Rendering added diff-line refs pinned costs 8 bytes in the two settle CLI
text samples (two `~s<gen>` suffixes). The output-economy baseline is the
tripwire for exactly this, so the increase takes an explicit reviewed waiver
rather than a silent baseline bump — the same one the settled TAIL's pins
already carry, for the same ADR 0014 reason.

Only `bytes` moves: lines, refs, hints, and shape are unchanged, which is the
evidence that this is a suffix on existing refs and not a new payload.

Caught by CI, not locally: `pnpm test:unit` runs unit-core and
subprocess-stub only, while the Coverage lane runs every vitest project.

* test: prove the generic settle degrades when its runtime cannot be built

`createGenericSettleRuntime` catches and returns undefined so an observation
that cannot even start does not fail an action that already succeeded. That
was a claim in a docstring with nothing behind it — the one changed line the
coverage gate reported uncovered (95/96).

The test puts the session in the state the catch exists for: the router
handed us a session that is no longer in the store, so building the settle
runtime throws SESSION_NOT_FOUND. The response keeps its scroll result and
simply carries no settle payload. Removing the try/catch fails it.

* build: teach fallow that vi.mock reaches pinOwnProcessStartTime dynamically

Not from this PR: #1642 added `pinOwnProcessStartTime` on main, and its three
consumers reach it the only way a Vitest module mock can —
`vi.mock(path, async (importOriginal) => (await import('...')).pinOwnProcessStartTime(...))`.
Dependency analysis cannot follow that dynamic import to a consumer, so the
export reads as dead the moment any PR pulls that file into its audit scope.
This PR is the one that did.

The entry records the consumers by path and the reason, matching the
daemon route-handler entry directly above it, which exists for the same
dynamic-`import()` limitation.

* refactor: adopt the best of the parallel #1653 implementation

Two sessions independently built #1638 (PR #1650 and PR #1653) and converged
on the same architecture — trait in the registry, one engine with two entry
points, runtime-command seam, lazy import, preserve-daemon, stored-baseline
honesty. #1650 continues; this folds in what #1653 did better:

- The agent-facing help core loop (cli-help.ts) now names scroll and back as
  settle-capable. Without this, the benchmarked closed-grammar help line kept
  instructing agents that --settle is only for press/click/fill/longpress —
  actively steering the AppControlBench models away from what #1638 shipped.
- issueSettleRefs moves into session-snapshot.ts, beside the partial-frame
  primitive it wraps, deleting the single-function settle-ref-issuance module.
- Their seam tests: back reader→writer settle plumbing, back CLI settle
  rendering, and a trait-less generic command (home) ignoring a stray settle
  flag rather than observing or rejecting.

What #1650 had that #1653 lacked, for the record: the SETTLE_REF_ISSUING_TOOLS
registry derivation (without it, MCP never pins a scroll/back settle diff's
refs and the partial frame rejects every follow-up), BackCommandResult.settle
in contracts, back's MCP output schema, paste-ready pinned diff refs, and the
docs/changelog/baseline surfaces.

* bench: help-conformance case for settled scroll-to-find planning

The #1638 extension of the closed --settle grammar to scroll/back is the
feature's entire payoff — collapsing scroll-then-observe into one call — and
the closed command list is an enumerated N whose enumerator is this bench.
The regex over the help text proves the sentence exists; this case checks
whether a model plans differently because of it.

One focused case, deliberately not coached: a pinned visible-first snapshot
(rendered by formatSnapshotText, pinned by the sample-producers gate) whose
wanted row is summarized off-screen with no ref anywhere in the output. The
tempting pre-#1638 plan is `scroll` plus a separate `snapshot -i`; acceptance
is the single settled call. Scoring was verified against eight plan shapes in
both directions before recording.

Model-backed record (claude-haiku-4-5, 3 trials, current help): 0/3 — but the
decomposition is the finding. Settle eligibility GENERALIZED (3/3 trials put
--settle on scroll unprompted; the mutation-suffix framing concern did not
materialize) and the two-call habit is residual (1/3). All three trials failed
on `scroll @e3 down --settle` — the pre-existing #1366 scroll-takes-no-target
confusion, which the live CLI recovers with a dedicated hint but a single-shot
bench cannot. The recorded gap is therefore a first-30 doc gap (nothing
teaches that scroll takes no target), not a settle-eligibility gap; tuning the
case until it passes would just delete the evidence.
2026-08-06 20:04:16 +02:00
Michał Pierzchała 611858103e fix(ios): harden Bluesky-class interaction reliability (#1588)
* fix: type into focused iOS inputs without AX

* fix: fill AX-hostile iOS text inputs

* fix: keep scrolling containers from stealing taps

* fix: stop agents after explicit task success

* chore: format benchmark guidance

* fix(ios): preserve fill semantics across fast paths

* test: retire direct selector fill expectations

* test: assert runtime selector fill evidence

* fix(ios): preserve verified and Maestro fill paths

* refactor(ios): isolate synthesized text entry

* fix(client): preserve open diagnostic paths

* fix(ios): expose structured text entry route

* fix(packaging): strip text entry policy tests
2026-08-05 08:02:44 +02:00
Michał Pierzchała 8ba5f9b8de fix: surface AMBIGUOUS_MATCH candidates and name find's supported actions (#1602)
* fix: surface AMBIGUOUS_MATCH candidates and name find's supported actions (#1597)

AMBIGUOUS_MATCH errors now list the matching candidates (ref, role,
label/identifier) rendered the same way as snapshot -i lines, capped at
5 with a "+N more" marker. buildAmbiguousMatchError (the single
producer, src/daemon/handlers/find.ts) reuses formatSnapshotLine to
build the list; formatAmbiguousMatchCandidateLines (src/utils/output.ts)
renders it unconditionally on both text surfaces an agent actually
reads (CLI printHumanError and MCP formatToolErrorText) — previously
the candidates lived only in details, which neither surface printed.

find's "Unsupported find action: X" (e.g. from `find <text> press`)
now attaches a hint naming every action find actually supports and the
two-step recovery shape: run find "<text>" to resolve the ref, then
dispatch the gesture as its own command (press @eNN). The hint is a
single exported constant (UNSUPPORTED_FIND_ACTION_HINT) shared by both
throw sites — packages/selectors' raw-token parser and the CLI's typed
reader (src/commands/interaction/selectors.ts) — so they can't drift.

Matching semantics are unchanged; ambiguous rejection stays by-design.
The help-conformance corpus's AMBIGUOUS_MATCH quiz is updated: its
premise ("candidate refs were not shown") no longer holds, but with 3
identically-labeled candidates the lesson (don't guess a specific ref)
still holds.

* fix: guard the AMBIGUOUS_MATCH candidate renderer against device-domain shapes

Review on #1602 (P2): formatAmbiguousMatchCandidateLines ran for every
normalized error and stringified details.candidates unconditionally,
but device-domain AMBIGUOUS_MATCH/APP_NOT_INSTALLED errors
(findBootedAppleSimulatorWithApp, src/core/dispatch-resolve.ts) reuse
that key for { id, name } device objects with no `matches` field —
CLI and MCP would have printed "Candidates: [object Object]" for
those. The renderer now requires numeric details.matches AND every
candidate to be a string before rendering anything, restricting it to
buildAmbiguousMatchError's element-match shape; unrecognized shapes
render nothing, same as before this feature existed. Added regression
tests against the exact device-error shape on both text surfaces.

Also unexports AMBIGUOUS_MATCH_CANDIDATE_LIMIT (fallow flagged it as
an unused production export) — it has no consumer outside find.ts.
2026-08-04 21:10:05 +02:00
Michał Pierzchała 2e74b789fd feat: verify device cloud connections (#1564)
* feat: verify device cloud connections

* refactor: unify connect provider adapters

* refactor: separate connect verification facts

* fix: tighten connect provider verification

* fix: use neutral cloud connection wording

* perf: deduplicate local affected checks

* refactor: simplify affected check runner

* refactor: derive connect workflow from verification
2026-08-03 16:47:57 +02:00
devin-ai-integration[bot] 7402a40bac test: enumerate error-code recovery quizzes in a unit-lane gate (#1445)
* test: enumerate error-code recovery quizzes in a unit-lane gate

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: mark recovery quizzes structurally and derive retriability from the enumeration

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-28 15:46:27 +02:00
Michał Pierzchała e8b779cb32 fix(daemon): keep close-time script-save failures from leaking the session/device claim (#1392)
* fix(daemon): keep close-time script-save failures from leaking the session/device claim

A close-time script write (implicit from `open --save-script`, or this
close's own `--save-script`) that refuses to publish (e.g. a no-clobber
target-exists AppError) threw uncaught out of `handleCloseCommand`,
skipping lease release, device-claim release, and `sessionStore.delete`
entirely — while the `close` action had already been recorded with no
rollback.

Live-repro'd over the real CLI against an Android emulator: this single
gap explained both symptoms split out of #1384 into #1391 — a lingering
`DEVICE_IN_USE` claim after a failed `close`, and a published `.ad`
rewritten with duplicated trailing `close` lines when the same close was
retried (each attempt re-recorded a `close` action on top of the one
never rolled back from the prior failure).

Catch the write failure, roll back the just-recorded `close` action
(mirroring the existing repair-armed commit-failure pattern), and let
teardown (lease release, device-claim clear, session delete) complete
regardless — exactly as an ordinary platform-close failure already
doesn't block them. The failure is still surfaced to the caller, but
after teardown, with a corrected hint: retrying the same close is no
longer meaningful since the session is now gone.

Fixes #1391

* refactor(daemon): shrink handleCloseCommand/runSessionCloseTeardown under fallow's complexity gate

CI's fallow code-quality check flagged handleCloseCommand (126 lines,
19 cyclomatic / 16 cognitive) and runSessionCloseTeardown (73 lines) as
exceeding the large-function/high-complexity thresholds after the
prior commit's fix.

Extract runCloseTeardownAndRelease (teardown + lease release + claim
clear + delete + ordered error surfacing) and buildCloseSuccessResponse
(final response shaping) out of handleCloseCommand, and
finalizeOrdinaryCloseScript out of runSessionCloseTeardown. No behavior
change — same control flow, split into named, independently-readable
steps; fallow now reports 0 complexity findings for this diff.

* fix(daemon): preserve the write error's structured details in the close-time save failure

Review feedback on #1392 (thymikee): toOrdinaryCloseSaveScriptFailure
rebuilt the AppError from only the original message, dropping its
machine-readable details.reason ("script_target_exists"), details.path,
and cause. A caller dispatching on those fields (or reading the CLI's
--json error.details) lost them even though the underlying write
failure carried them.

Preserve the original error's details/cause, overriding only the
close-specific hint and retriable:false. Extends the #1391 regression
test to assert the routed close response still carries reason/path.

* refactor(daemon): drop the vestigial close-time rollback, add router-level #1391 coverage

Review feedback on #1392 (thymikee), P2 items:

- The close-time save-script failure's session.actions rollback
  (finalizeOrdinaryCloseScript) was left over from an earlier design
  where a failed save could keep the session alive for retry. It
  never does now — runCloseTeardownAndRelease always tears the
  session down regardless of the outcome — so there is no surviving
  session for a later write to duplicate the close action on. Drop
  the rollback; the durable events.ndjson entry (which the rollback
  never touched anyway) and the in-memory action now agree, both
  accurately recording that the close happened.
- Add a request-router-level regression (request-router-typed-error.test.ts,
  alongside the existing repair-close BLOCKER 2 test it mirrors) proving
  the normalized JSON error shape a real client sees: top-level
  retriable:false, details.reason/path preserved, and the session torn
  down — not just that handleCloseCommand throws the right AppError
  when called directly.

* test(daemon): assert the durable close event survives a failed close-time save

Review feedback on #1392 (thymikee), final P2 item: the previous commit
removed the actions rollback because there's no surviving session to
duplicate the close action on, but nothing actually asserted the
durable events.ndjson action.recorded:close event stays put. Flush and
read it back so a future rollback or event-order change can't silently
recreate the in-memory/durable mismatch the removed rollback used to
paper over asymmetrically.

* test(daemon): assert the retained session's in-memory close action, not just the durable event

Review feedback on #1392 (thymikee): the durable-event assertion alone
doesn't catch a reintroduced session.actions.length = actionsBeforeClose
rollback, because that event is queued (and durable) before the write
even attempts — a regression there would leave the assertion passing
while silently reintroducing the in-memory/durable mismatch.

Retain the session object past handleCloseCommand (store.delete only
drops the map entry, not the object a local variable still points at)
and assert its actions array contains exactly one close entry, matching
the durable event count. Verified by temporarily reintroducing the old
rollback locally: this assertion fails (0 !== 1) where the prior
durable-only check did not, then reverted.

* refactor(daemon): model repair close retry as receipt

* refactor(daemon): merge blockingError to state, not explain, the save-script exclusion

Following up on the comment-trimming pass already on this branch: the
device-claim condition (!platformCloseError && !cleanupAggregate) and
the two-line throw sequence right below it both needed a paragraph
explaining why saveScriptError is excluded from one but not the other.

Merge platformCloseError and cleanupAggregate into a single named
blockingError — its name now states the exclusion the comment used to
argue for, and the throw sequence collapses from two ifs to one.
Trimmed the remaining long docblocks in this file the same way: state
what's non-obvious in 1-3 lines instead of re-deriving it in prose.

* refactor(daemon): clarify close script finalization
2026-07-27 16:04:38 +02:00
Michał Pierzchała 2d1d70613f feat(bench): renderer-pinned samples, topic-coverage gate, error-recovery quizzes; trim skillgym to agentic checks (#1411)
* feat(bench): renderer-pinned samples, topic-coverage gate, error quizzes; trim skillgym to agentic checks

The help conformance bench's quoted CLI output is now sourced from
scripts/help-conformance-sample-outputs.mjs, and every sample is rebuilt
through the real production renderers (settle output formatters,
printHumanError, formatSnapshotText, refMutationAdmissionResponse) by
scripts/__tests__/help-conformance-sample-outputs.test.ts — a rendering or
message change fails deterministically instead of leaving the bench grading
against output the CLI no longer prints. This retires the fabricated
recoverable-failure envelope (production never throws a textual settle
timeout; that case is replaced by a real DEVICE_IN_USE recovery quiz).

Bench cases move to scripts/help-conformance-cases.mjs and are enumerated
against the help-topic registry: helpTopicIds() is exported from cli-help,
and scripts/__tests__/help-conformance-topic-coverage.test.ts fails when a
help topic has neither a bench case nor an explicit waiver. New case
families: error-envelope recovery quizzes (device-in-use, stale pinned ref,
ambiguous find match, app-not-installed) pinned to real error text, topic
coverage for tv/web/react-native/debugging/workflow, and a metamorphic twin
of the settled-diff quiz.

The skillgym smoke suite shrinks from 119 cases to the 5 that measure what
only an agentic runner can show: skill routing plus output interpretation
with a proven local CLI help probe (local-cli-help-policy). Its embedded
samples now import the same pinned constants, replacing hand-transcribed
output that had already drifted from the renderer. Knowledge checks belong
to the bench; live fixture behavior belongs to the iOS simulator e2e suite.

* review: drive error samples through the real producers; enforce local-help on the routing smoke

The DEVICE_IN_USE, AMBIGUOUS_MATCH, and APP_NOT_INSTALLED parity tests no
longer hand-author the producer message before rendering: each drives the
actual producer — buildDeviceInUseBySessionError (extracted in
session-open.ts and called by the handler), buildAmbiguousMatchError (now
exported from find.ts), and buildAppNotInstalledError (extracted in
app-resolution.ts and thrown by the resolver). Because each factory is
exported from its producer file and called by the production path, dropping
the production call would make it test-only and fail
check:production-exports — the wiring is gate-enforced, not conventional.

open-and-snapshot now sets requireLocalCliHelp and
allowOnlyLocalCliHelpCommands, so the 'skill plus local help' claim is
observed rather than assumed; without them the case can pass on model prior
alone.
2026-07-27 14:24:36 +02:00