Skip to content

Inc 2: the two destructive lifecycle paths stop guessing (L4, L5, L9), and the README stops deferring (P8) - #16

Merged
fluxxion82 merged 4 commits into
mainfrom
fix/inc-2-erase-delete-readme
Sep 3, 2026
Merged

Inc 2: the two destructive lifecycle paths stop guessing (L4, L5, L9), and the README stops deferring (P8)#16
fluxxion82 merged 4 commits into
mainfrom
fix/inc-2-erase-delete-readme

Conversation

@fluxxion82

@fluxxion82 fluxxion82 commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Inc 2 of the v0.7.0 plan: L4, L5, L9, P8 — plus Codex round 1 (R1–R6, R8).

Rebased onto 701ed7d (#14). Counts: 1613 passed / 0 xfailed on origin/main, 1652 passed / 0 xfailed here. Measured by cp-snapshotting the changed files, git checkout 701ed7d -- them, running, and restoring from the snapshots. No xfail(strict=True) marker covered any of these findings, and #14 removed the last markers on this tree, so none was deleted.


Codex round 2

All six addressed. 1613 passed on 701ed7d → 1652 passed here. KNOWN_VIOLATIONS 6 → 5 (F4 paid one off).

F2 (P1) — the listing failing is the third gap

EmulatorProbe already covered two gaps: an emulator not in state device, and one whose console will not answer. adb devices itself failing is a third, and all three scripts handled it differently and all three were wrong:

script what it did with a failed listing
emulator_selector except RuntimeError: return [] — ranked every AVD as idle, silently
emulator_boot let it escape as a bare RuntimeError → traceback
emulator_shutdown same

The escape is worth spelling out: get_connected_devices re-wraps a failed listing as a plain RuntimeError, which is not an AdbError, so main()'s handler never saw it — a stack trace, from the one module family whose whole purpose is turning adb failures into remedies.

common/emu_console.py now has EmulatorSurvey and survey_emulators. The listing is a value, phrased once as running state unavailable: <reason>, and the listing callable is passed in so each script keeps the seam its own tests already stub. Callers differ where they should:

  • selector ranks what it can — the AVDs on disk are real and a suggestion is not destructive — and discloses the gap: warnings in the JSON document, an indented ! line in the text listing, stderr in both.
  • boot refuses: if what is running cannot be listed, a boot may start a second instance of an AVD that is already up.
  • shutdown refuses, --name and --all alike.

One correctness note beyond the brief: describe_gap() appends UNKNOWN_EMULATOR_REMEDY only to the unidentified case. An adb_exec error already names its own remedy, and bolting "restart the emulator, or reset the adb connection" onto "adb is not on PATH" is advice that does not work.

Mutations (cp snapshot → edit → pytest → cp restore):

mutation tests red
survey_emulators swallows the failure (unavailable=None) 12, across boot, shutdown and selector
boot's if survey.unavailable: disabled test_boot_refuses_when_the_emulators_cannot_be_listed, test_cli_reports_an_adb_error_without_a_traceback
shutdown's UNKNOWN branch disabled test_shutdown_by_name_refuses_when_an_emulator_could_not_be_queried, ..._when_the_emulators_cannot_be_listed, test_cli_reports_an_adb_error_without_a_traceback[by-name]

New tests read adb_device_not_found via recorded_anywhere, as instructed.

F1 (P1) — --all --json exited 1 with an empty stdout

emulator_shutdown.main()'s adb handler sat outside the arg parsing — R1's defect, still live. Parsing happens first now and the handler answers in the mode it was asked in. emulator_boot had the identical shape and got the identical fix.

The test is inverted as instructed: test_cli_reports_an_adb_error_without_a_traceback now requires {"error": ...} on stdout and an empty stderr for the --json parametrisation, prose on stderr for the text ones. Its docstring says why it stayed green — it asserted prose for the JSON case too.

Mutation: _fail back to prose-only → [all-json] and test_shutdown_by_name_when_the_emulators_cannot_be_listed go red.

F3 (P1) — the remedy needed the console that was not answering

Leads with the shared UNKNOWN_EMULATOR_REMEDY, then says explicitly to terminate the stale emulator process (pkill -f 'qemu-system.*<avd>', or Device Manager) — emulator_shutdown cannot reach a console that is not answering.

Mutation: revert to "Shut that emulator down with emulator_shutdown.py" → test_boot_refuses_while_an_emulator_cannot_be_identified red.

F4 (P1) — invented tool output, and a debt that was already paid

emulator -list-avds now comes from recorded.text("emulator_list_avds") in both selector tests. The framed console reply comes from recorded.text("emu_avd_name") with only the AVD name substituted, through a _framed_reply helper whose docstring says why: the OK framing is the part nobody would write by hand and the part that defeated the already-booted check for a whole release (S5).

That paid off KNOWN_VIOLATIONS' test_emulator_shutdown.py::_FakeResult — and the ratchet caught it itself: test_the_debt_list_does_not_rot failed with "listed as known violations but no longer violate anything". Entry deleted, the payoff recorded in the PAID OFF SINCE block. 6 → 5.

config.ini: no profile records one and none can be captured here, so per your instruction the read-from-disk test reuses this file's single existing literal — hoisted to a module constant so it stays visible to the detector under the existing parse_config_ini freeze rather than becoming a second invention. Recording an AVD config.ini is the next recording candidate.

F5 (P2) — one failure shape, and the promise made true

Ordinary failures in emulator_erase, emulator_shutdown and emulator_boot go through one _fail helper — the (error, *, json_mode) spelling emulator_boot and emulator_selector have had since #15. My round-1 _fail(args, message) was a second helper that shadowed boot's existing one; that is gone.

Batches keep their per-AVD summary and carry error alongside it when any AVD failed, in emulator_delete too so the two destructive scripts do not disagree. Three "Error: " prefixes embedded in returned messages are removed — the helper adds exactly one.

Mutation: delete erase's if not success: _fail(...) → three tests red, including the new test_an_ordinary_failure_answers_with_the_documented_error_key.

F6 (P2)

Docstring corrected, with a note that KNOWN_CONSOLE_CALLS is the count that matters because a number in prose goes stale silently — which that one did. No behavioural test, so no mutation: the enumeration is its own check.

A fourth existing-test assertion changed

test_emulator_shutdown.py::test_cli_exits_non_zero_when_refusing_a_physical_device asserted the L1 refusal appears on stdout. Under F5 a refusal is a failure and goes to stderr. Its invariants — exit 1, the serial named, no adb command issued — are unchanged and still asserted, plus a new assert not captured.out so a refusal cannot be printed as a result again.

Honest note on the F6 mutation run

The batched mutation run hit the 2-minute tool timeout mid-f6-stale-count, after the edit and before the restore, and because the runner snapshots before asserting, the snapshot was the mutated file. I repaired the docstring by hand and verified with git diff. The runner's snapshot-then-assert order is the wrong way round for a mutation that does not apply; the other twelve restored correctly.


Codex round 1

R3 (P1) — "unknown means not running" in three more scripts

This is L4 again, and the fix is one function rather than four. Four places asked the same question — which AVD is this serial running? — each with its own console call, its own timeout, and its own idea of what a failure means:

script function caught answered
emulator_boot _get_avd_name_for_serial AdbError None
emulator_erase is_avd_running AdbCommandError / EmuConsoleError False
emulator_selector _avd_name_for_serial EmuConsoleError only None
emulator_shutdown get_avd_name_for_serial bare Exception None

Four answers to one question, and every one collapsed "I could not ask" into "it is not that AVD". Each collapse authorised something: a wipe (L4); a second instance of a live AVD (boot's already-booted check filtered out every emulator not in state device, so one mid-boot was invisible); a ranking that silently dropped what it could not read; and --name X reporting "No running emulator found" over a device nobody had managed to query.

common/emu_console.py now holds:

  • identify_emulator(serial, state) — the only run_emu("avd", "name") call in the skill. Never raises for a console or command failure: that outcome is the answer, carried as reason alongside the serial and adb's state column. A non-device state short-circuits without asking, because a console that is not up cannot answer.
  • probe_emulators(devices=None) — one probe per attached emulator. The optional devices argument lets a caller pass a listing it already has, which keeps the listing on that caller's own seam (where its tests stub it) and avoids querying adb twice.
  • avd_running(name)RunningAnswer with a RunningVerdict of RUNNING / NOT_RUNNING / UNKNOWN. UNKNOWN beats NOT_RUNNING and loses to RUNNING — that ordering is the safety property.

The four callers now differ where they should, in what they do about UNKNOWN:

  • erase refuses, naming the serial and its state; --force still erases.
  • boot refuses and warns on stderr. Codex's instruction allowed proceeding "if the requested AVD is not the unknown one's name when it can be read" — for an unidentified emulator the name cannot be read by definition, so that clause resolves to "refuse", which is what this does. emulator_boot has no --force, and I did not add one; flag me if you want the escape hatch.
  • selector ranks without it but lists it: unidentified_emulators in the --json document, an indented ! line in the text listing, and a stderr warning in every mode, so a --json caller still gets a clean stdout.
  • shutdown --name exits non-zero saying which serial could not be queried and why, via a new resolve_target_by_avd_name returning (serial, reason). resolve_serial_by_avd_name is kept as its lossy wrapper.

Console-call enumeration delta

tests/test_shape_guards.KNOWN_CONSOLE_CALLS, 9 → 6:

before after
common/emu_console.py::identify_emulator added
common/emu_console.py::console_available
emulator_boot.py::_get_avd_name_for_serial removed
emulator_erase.py::is_avd_running removed
emulator_selector.py::_avd_name_for_serial removed
emulator_shutdown.py::get_avd_name_for_serial removed
emulator_shutdown.py::shutdown
location.py::_run_geo_fix
sms.py::send
snapshot.py::_console

The four removed are recorded in a comment above the list with what each of them used to catch, so the enumeration keeps the history the guard exists to protect. The guard failed on the first run of the rewrite and named exactly those four — the delta is its output, not my summary of it.

Three existing tests changed, all in the same direction

Called out individually because none is an xfail spec and each pinned the behaviour R3 says is wrong:

  1. test_emulator_erase.py::test_a_device_error_is_not_answered_as_not_running — asserted pytest.raises(DeviceNotFoundError) out of erase(). A device error on one emulator's console is now an UNKNOWN verdict instead. Strictly more information reaches the agent: the refusal names which serial went unanswered and quotes adb's own message and remedy, where the exception said only that something had. The safety assertion (userdata still on disk) is unchanged and the test now also asserts the serial and the remedy appear.
  2. test_emulator_selector.py::test_a_device_error_is_not_ranked_as_not_running — asserted the same raise out of running_avd_names(), which made a suggestion fatal over a condition it cannot fix. Its invariant ("an unanswered check must not read as idle") is unchanged and still asserted — no name is invented, and the emulator is reported unidentified. The thing it was really protecting (a second copy of a live AVD being booted) is now refused by emulator_boot, which is the only place that can actually prevent it.
  3. test_emulator_selector.py::test_cli_reports_an_adb_error_without_a_tracebacktest_the_cli_names_an_emulator_it_could_not_identify. --suggest no longer exits 1 over one unauthorized emulator; it ranks what it can and warns. The remedy and no-traceback assertions are kept verbatim, and selector's CLI failure boundary is still covered by test_the_cli_exits_non_zero_when_avd_discovery_fails and test_a_json_caller_gets_the_failure_in_the_json.

Also: test_emulator_erase.py::test_running_check_uses_adb_command_mapping now expects adb devices -l, because the listing goes through device_utils.get_connected_devices rather than this script's own row parser.

R1 (P1) — --json promised {"error": ...} and printed nothing

main() caught AdbError outside the arg parsing, so it could not know --json had been asked for: --name Pixel_9 --json under a RunningCheckError gave exit 1, prose on stderr, and an empty stdout — nothing at all on the stream an agent parses. Parsing now happens first and _fail(args, message) answers in the mode it was asked in. The --name is required usage error had the same shape (argparse help, no JSON) and is fixed with it.

New test test_every_failing_json_mode_prints_an_error_document, parametrised over --name … --json, --all --json and a bare --json.

R2 (P1) — the clone fallback installed something that is not a skill

git clone <url> ~/.claude/skills/android-emulator-skill puts the repository root there, but the skill is android-emulator-skill/skills/android-emulator-skill/ — two levels down. Claude Code loads nothing. Both README.md and SKILL.md now clone to a workspace path and symlink (or cp -R) the inner directory into ~/.claude/skills/ or .claude/skills/.

The guard resolves it rather than matching a string: _clone_installations parses the fenced block for a git clone <url> <dest> and each later ln -s / cp -R whose source starts with that destination, strips the destination prefix to get a path relative to the repository, and asserts REPO_ROOT / relative / "SKILL.md" is a file.

R4 (P1) — read_avd_config hard-coded ~/.android/avd

Now resolve_avd_home(). On a host that relocates the AVD tree every config read returned {}, so every AVD lost its API level, device profile and ABI from the ranking — a ranking computed from no inputs and printed as a recommendation. Tested with ANDROID_AVD_HOME set.

R5 (P2) — the refusal recommended a remedy that cannot work

An offline emulator cannot be console-killed, so emulator_shutdown.py --all was not merely second-best, it was the thing that will not work. UNKNOWN_EMULATOR_REMEDY ("restart the emulator, or reset the adb connection with adb kill-server && adb start-server") comes first now, carried by RunningAnswer.describe_unknown() so all four scripts quote the same fix.

R6 (P2) — Pillow is optional in the packaged README too

R8 (P2) — the guard claim was wrong on all three counts

Codex is right and my PR body was wrong. There are five guard functions, not six (I counted parametrisations). Two read prose, not fenced blocks. And two placeholder parametrisations were green on main — my earlier "all six red" transcript mutated the root README into a state main never had, which is evidence of nothing.

What changed:

  • Every guard now declares which stream it reads. _code_block_lines is the commands a reader copies — a <placeholder> there is a defect. A new _prose_lines is the claims the document makes — a deferral sentence is a defect there, a placeholder is not. Neither reads the whole file.
  • The deferral guard is no longer parametrised over the packaged README, which never carried the sentence: a guard that was green before the change it claims to enforce is not evidence. The placeholder guard keeps its clean parametrisations, but they are labelled in the docstring as the false-positive control rather than as evidence.
  • The deferral guard matched line-at-a-time and so was blind to wrapped prose. Its own mutation caught this: inserting the sentence with a newline inside it left the guard green. It now joins the prose stream before matching. That is the one place in this PR where writing the transcript found a real defect in the thing being written.

Per-guard mutation transcript

Runner: cp snapshot → edit → pytest -xcp restore, asserting the anchor text occurs exactly once so a stale mutation fails loudly instead of silently testing nothing. Never git checkout --.

cp <target> /tmp/mutate-snapshot.py       # snapshot
# ...apply the single-anchor replacement below...
uvx --python 3.12 --with pytest --with pillow --with pyyaml pytest <tests> -q --no-header -x
cp /tmp/mutate-snapshot.py <target>       # restore
# mutation (what main had) test that went red failing assertion
1 SKILL.md: real URL → git clone <repository-url> test_no_documented_command_still_carries_a_placeholder[skill] SKILL.md documents commands nobody can run: ['git clone <repository-url> ~/src/android-emulator-skill']
2 README.md: deferral sentence restored, wrapped across two lines test_the_repo_readme_does_not_defer_its_install_instructions README defers its own instructions: 'Plugin install instructions will be finalized once feature parity work lands:'
3 README.md: claude plugin marketplace update line deleted test_the_readme_documents_both_halves_of_an_update README does not show 'claude plugin marketplace update'
4 README.md: clone URL → a different owner test_the_clone_url_points_at_this_repositorys_owner assert False … any(<genexpr>)
5 README.md: link the clone root into .claude/skills (main's own instruction) test_the_clone_fallback_installs_a_directory_that_is_actually_a_skill[repo] where False = (PosixPath('<repo>') / 'SKILL.md').is_file
6 README.md: the $ANDROID_HOME/emulator warning generalised away test_the_readme_names_the_emulator_path_trap re.search('not\s+?$ANDROID_HOME?[,.\s]', prose)None

Only #1 and #5 correspond to text main actually had; #2#4 and #6 are anti-vacuity checks on guards protecting text this PR adds. Stated rather than blurred, which is the correction R8 asked for.

Code mutation transcript (R1, R3, R4, R5)

# mutation (what main had) test that went red failing assertion
7 _fail always writes prose to stderr (handler outside arg parsing) test_every_failing_json_mode_prints_an_error_document[name] json.decoder.JSONDecodeError: Expecting value: line 1 column 1
8 probe_emulators filters to state == "device" (what all four callers did) test_boot_refuses_while_an_emulator_cannot_be_identified 'emulator-5554' in 'Boot error: a second emulator was launched over an unidentified one'
9 emulator_boot: unidentified = [] test_boot_refuses_while_an_emulator_cannot_be_identified same — the test's Popen stub raises, so the launch is proved, not inferred
10 emulator_shutdown: the UNKNOWN branch deleted test_shutdown_by_name_refuses_when_an_emulator_could_not_be_queried 'emulator-5554' in "Error: No running emulator found for AVD name 'Pixel_9'"
11 emulator_selector: self.unidentified = () test_suggest_lists_an_emulator_it_could_not_identify [p.serial for p in selector.unidentified] == [serials[0]]
12 read_avd_config back to Path.home() / ".android" / "avd" test_read_avd_config_honours_the_resolved_avd_home config read from the real home instead of $ANDROID_AVD_HOME
13 erase refusal recommends emulator_shutdown --all first test_a_device_error_is_not_answered_as_not_running 'emulator-5554' in 'Refusing to erase Pixel_9: … Shut the emulators down (emulator_shutdown.py --all).'

#8 and #9 both land on the same test from different directions — the shared filter and the caller's use of it — which is what makes the pair worth keeping.

Every R3 test is driven from recorded.text("adb_devices_multiple") (two emulators) and recorded.text("emu_avd_name"). The offline state is the recorded row with its state column rewritten, and each such test says so in its docstring: no profile has recorded a device in that state (see test_fixture_policy.KNOWN_VIOLATIONS' parse_adb_devices entry), and provoking one means killing an emulator mid-boot on the recording host. KNOWN_VIOLATIONS is unchanged.

R7

Noted as going to the v0.8.0 list, not actioned here.


The original findings

L4 — is_avd_running said "not running" four ways it had not established

scripts/emulator_erase.py. Every one leads into the unlink loop.

  • (a) One failing console query ended the whole scan: the try wrapped the loop, so an AdbCommandError from the first emulator returned False with later emulators never examined.
  • (b) Only the literal state device counted, so an emulator still booting (offline) was invisible. The row filter also stopped being "emulator" in line and "device" in line — against adb devices -l every row carries a device:emu64a16k descriptor, so that predicate is true of a row in any state.
  • (c) The name test was a substring, so --name Pixel was refused while an unrelated Pixel_9 ran. Now equality on the unframed payload. This hole leans the opposite way: name in payload is a strict superset of name == payload, so it over-refuses and can never cause a wipe equality would prevent. Its test asserts the erase of a differently-named AVD now proceeds, with a companion asserting the true match still refuses. The brief's "never reach unlink in any of them" holds for (a) and (b) but is inverted for (c).
  • (d) A failed adb devices returned False — the check never ran at all. Now RunningCheckError, naming adb kill-server and --force.

The three holes' mutation transcripts (equality → substring; state column → main's line filter, whose failure message is the defect verbatim, AVD erased: Pixel_9 (deleted 2 files); per-emulator failure → main's scan abort) are in the commit history of this branch and reproduce unchanged; the L4 logic now lives in avd_running and its mutations are #8 above.

L5 — --old N deleted the AVDs it could not find

_mtime returned 0.0 on OSError, so in a newest-first sort every AVD the script could not locate went to the end — which is the end --old deletes. --old 3 on a host whose AVD home is elsewhere proposed deleting the lot, and the only symptom was that it appeared to work. Now AvdHomeError (subclasses SdkToolError, so every mode reports it the one way the L8 fix established) naming the directory searched and the two variables that relocate it.

AVD home is resolved as avdmanager resolves it: ANDROID_AVD_HOME, else ANDROID_SDK_HOME/.android/avd, else ~/.android/avd. Note the asymmetry — the first is the directory of .avd directories, the second is the parent of .android. grep found the same two-branch resolver in emulator_erase.get_avd_home and (via R4) emulator_selector.read_avd_config; all three call one common.sdk_tools.resolve_avd_home().

Also, from the same finding's evidence: --old 0 keeps nothing, so it silently meant --all. Now a usage error (exit 2) pointing at --all --yes.

Mutations: _mtimereturn 0.0 reddens both L5 tests; dropping the ANDROID_SDK_HOME branch reddens [sdk_home]; removing the --old 0 guard reddens both parametrisations.

L9 — snapshots kept, and now said so

A snapshot is a whole guest machine, not user data, so deleting one during an erase would throw away state somebody recorded deliberately — but a later snapshot.py --load can undo the "factory state" this script promises. One line, in the module docstring, on every successful erase (text and --json, single and --all), and in the SKILL.md entry. <name> is inserted into the brief's wording because snapshot.py --delete takes a NAME metavar; test_the_snapshot_note_names_a_flag_snapshot_py_actually_has parses snapshot.py's argparse with ast to confirm the flag exists.

P8 — install instructions somebody can run

Deferral sentence deleted; both install commands kept; the @fluxxion82 suffix explained as marketplace.json's name field; the two-command update with its silent-failure note; a clone fallback that installs the right directory (R2); and a Prerequisites list — platform-tools; cmdline-tools with the reason the legacy tools/bin/avdmanager cannot substitute (NoClassDefFoundError: javax/xml/bind/annotation/XmlSchema on Java 11+); Java 21; Python 3.12+; Pillow optional; and the $ANDROID_HOME vs $ANDROID_HOME/emulator trap. No script table. The Status paragraph, two lines above the deleted sentence, still claimed parity was pending and was corrected with it.


Other enumerations

Enumeration before after
KNOWN_CONSOLE_CALLS 9 6 (table above)
KNOWN_EMU_BYPASSES () unchanged
KNOWN_VIOLATIONS (fixture policy) 6 5 (F4 paid one off)
KNOWN_BOUNDS_SITES () (emptied by #14) unchanged

SKILL.md ↔ argparse parity is green. --name was added to emulator_shutdown's documented options — it exists and was simply missing from the list.

Lane failure on the first round-1 push, and what it was

The mocked check went red on test_a_successful_erase_says_snapshots_were_kept while the same test passed here. It stubbed is_avd_running, but after the R3 rewiring erase() consults running_check — so the real probe ran, found adb on my PATH and nothing on the runner's.

The exit status was the symptom; the defect is a unit test reaching the adb boundary at all. Fixed at the right seam, and subprocess.run is now stubbed to raise in that test, so anything under it that tries to talk to a device fails loudly rather than depending on whose machine it is.

Verification

uvx --python 3.12 --with pytest --with pillow --with pyyaml pytest tests/ -q
1652 passed, 1 skipped, 25 deselected

uvx --python 3.12 --with black==26.5.1 black --check …   117 files would be left unchanged
uvx --python 3.12 --with ruff==0.15.14 ruff check …      All checks passed!

Run on the tree rebased onto origin/main@701ed7d immediately before pushing. No adb, emulator, skill script or pytest -m emulator was run at any point — a physical phone is attached to this host.

…DME stops deferring

L4 -- emulator_erase.is_avd_running answered "not running" three ways it had
not established, and each one leads straight into `unlink`:

- The whole scan sat in one `try`, so an AdbCommandError from the FIRST
  emulator's console query returned False before any later emulator was
  examined. The try is now per-emulator; a failed query is recorded and the
  scan continues, because a later emulator may be a positive identification.
- Only the literal state `device` counted, so an emulator still booting
  (`offline`) was invisible and its AVD was wiped underneath it. Any non-
  `device` state on an `emulator-` serial now counts as running -- its console
  cannot be asked which AVD it is, so it may be this one. The row filter also
  stops being `"emulator" in line and "device" in line`, which with
  `adb devices -l` matches a row whose state is anything at all, because every
  row carries a `device:emu64a16k` descriptor.
- The name test was a substring, so `--name Pixel` was refused while an
  unrelated `Pixel_9` ran. It is equality on run_emu's unframed payload.

A failed `adb devices` no longer answers False either: it raises
RunningCheckError, which names `adb kill-server` and `--force`. The refusal
message is a disjunction ("is this AVD, or could not be identified") because
that is what the check establishes; claiming more is the defect being fixed.

L5 -- emulator_delete `--old N` ranked by an mtime that fell back to 0.0 on
OSError, sorting every AVD it could not find to the end of a newest-first list,
which is the end `--old` deletes. It now fails with AvdHomeError naming the
directory it searched and the two variables that relocate it. The AVD home is
resolved the way avdmanager resolves it -- ANDROID_AVD_HOME, else
ANDROID_SDK_HOME/.android/avd, else ~/.android/avd -- in one function,
common.sdk_tools.resolve_avd_home, since emulator_erase held a second copy of
the same two-branch resolver. `--old 0` keeps nothing, so it silently meant
`--all`; it is now a usage error (exit 2) pointing at `--all --yes`.

L9 -- snapshots are deliberately NOT deleted by an erase, but a later
`snapshot.py --load` can undo the factory state, so a successful erase says
"snapshots kept; use snapshot.py --delete <name> to remove them". A test checks
that flag against snapshot.py's own argparse rather than against memory.

P8 -- README drops "will be finalized once feature parity work lands", gains
the two-command update (the plugin update alone does nothing and fails
silently), a clone fallback with the real URL, and a Prerequisites list
including the trap that put `$ANDROID_HOME` on PATH instead of
`$ANDROID_HOME/emulator`. SKILL.md's `<repository-url>` placeholder is gone.
Six guards in test_packaging_contract.py hold it, reading fenced code blocks
rather than whole files so prose about placeholders is not a false positive.

1563 passed / 24 xfailed before, 1587 passed / 24 xfailed after. No xfail
marker covered these findings, so none was deleted.
…tions that install something

R3 is the round's real finding, and it is L4 again in three more scripts. Four
places asked the same question -- "which AVD is this serial running?" -- each
with its own console call, its own timeout and its own idea of what a failure
means:

  emulator_boot       _get_avd_name_for_serial   caught AdbError          -> None
  emulator_erase      is_avd_running             caught AdbCommandError   -> False
  emulator_selector   _avd_name_for_serial       caught EmuConsoleError   -> None
  emulator_shutdown   get_avd_name_for_serial    caught bare Exception    -> None

Four answers, and every one collapsed "I could not ask" into "it is not that
AVD". That collapse authorised a wipe (L4), a second instance of a live AVD
(boot's already-booted short-circuit filtered out every emulator not in state
`device`), a ranking that silently dropped what it could not read, and a
`--name X` shutdown reporting "No running emulator found" over a device nobody
had managed to query.

common/emu_console.py now holds one `identify_emulator`, whose failure is a
value with the serial, the adb state and the reason in it, plus `probe_emulators`
and a tri-state `avd_running` (RUNNING / NOT_RUNNING / UNKNOWN, where UNKNOWN
beats NOT_RUNNING and loses to RUNNING). The four callers now differ where they
should -- in what they DO about unknown:

- erase refuses, naming the serial and its state; --force still erases.
- boot refuses and warns on stderr: an unidentified emulator may be this AVD,
  and two instances of one AVD corrupt its userdata.
- selector ranks without it but lists it -- `unidentified_emulators` under
  --json, a stderr warning otherwise -- so the ranking never claims a
  completeness it does not have.
- shutdown --name exits non-zero saying which serial could not be queried,
  instead of a confident "nothing to shut down".

KNOWN_CONSOLE_CALLS goes from nine sites to six; the four that collapsed are
recorded in a comment above it.

R1 -- emulator_erase's CLI handler sat outside the arg parsing, so it could not
know --json had been asked for: a RunningCheckError gave exit 1, prose on
stderr and an EMPTY stdout. Parsing happens first now and `_fail` answers in the
mode it was asked in, including for the --name usage error.

R2 -- the clone fallback installed the REPOSITORY ROOT at
~/.claude/skills/android-emulator-skill. SKILL.md lives two levels down, so that
directory is not a skill Claude Code can load. Both documents now clone to a
workspace path and symlink the inner directory, and the guard resolves the
documented source against this repository rather than matching a substring.

R4 -- emulator_selector.read_avd_config hard-coded ~/.android/avd, so on a host
that relocates the AVD tree every config read returned {} and every AVD lost its
API level, profile and ABI from the ranking. It uses resolve_avd_home().

R5 -- the unknown-emulator refusal recommended `emulator_shutdown --all` first,
which cannot console-kill an offline emulator. Restarting the emulator or the
adb connection comes first now.

R6 -- the packaged README lists Pillow as optional, as the root README does.

R8 -- the claim of "six fenced-block guards, all red on main" was wrong on all
three counts. There are five guard functions; two read prose, which is correct
for a claim but was not stated; and two placeholder parametrisations were green
on main. Each guard now declares which stream it reads (`_code_block_lines` for
commands, a new `_prose_lines` for claims), the deferral guard joins wrapped
prose before matching -- a line-at-a-time version passed against a README that
had the sentence in it -- and the PR body carries a per-test mutation transcript.

1613 passed on 701ed7d, 1648 passed after. No xfail markers exist on this tree.
@fluxxion82
fluxxion82 force-pushed the fix/inc-2-erase-delete-readme branch from 3f59fe1 to bcea86e Compare September 3, 2026 10:22
`test_a_successful_erase_says_snapshots_were_kept` stubbed `is_avd_running`,
but after the R3 rewiring `erase()` consults `running_check` -- so the real
probe ran. It passed here, where adb is on PATH, and failed on the CI runner,
where it is not.

The exit status is the symptom. The defect is a unit test reaching the adb
boundary at all, so the fix is not only the right seam: `subprocess.run` is
now stubbed to raise, and the test fails loudly if anything under it tries to
talk to a device again.
…shape

F2 is the round's finding and it is the same collapse one layer out. The two
gaps EmulatorProbe already covered were "this emulator is not in state device"
and "its console would not answer". The third is `adb devices` itself failing,
and all three scripts handled it differently and all three were wrong:
emulator_selector caught every RuntimeError and ranked EVERY AVD as idle
without a word; emulator_boot and emulator_shutdown let it escape as a bare
RuntimeError -- get_connected_devices re-wraps a failed listing as one, which
is not an AdbError, so it reached the user as a traceback from the module
family whose whole job is turning adb failures into remedies.

common/emu_console.py now has EmulatorSurvey and survey_emulators: the listing
is a VALUE, phrased once as "running state unavailable: <reason>". The listing
callable is passed in, so each script keeps the seam its tests stub. Callers
differ where they should: selector ranks what it can and discloses the gap
(`warnings` in JSON, an indented line in text, stderr in both); boot and
shutdown refuse with a structured non-zero failure. describe_gap appends the
emulator remedy only to the unidentified case -- an adb_exec error already
names its own, and "restart the emulator" is not the fix for "adb is not on
PATH".

F1 -- emulator_shutdown's adb handler sat outside the arg parsing, so
`--all --json` exited 1 with prose on stderr and an EMPTY stdout: R1's defect,
still live here. The test asserted that old behaviour for the JSON
parametrisation, which is why it stayed green; it now requires
`{"error": ...}` on stdout for --json and prose only for the text modes.
emulator_boot had the same shape and got the same fix.

F3 -- the refusal told you to shut down an emulator whose console is the thing
not answering. It now leads with the shared remedy and says to terminate the
stale emulator process itself.

F5 -- SKILL.md promised every JSON failure is `{"error": ...}` while a missing
AVD emitted `{"success": false, ...}`. Ordinary failures in erase, shutdown and
boot go through the one `_fail` helper -- the `(error, *, json_mode)` spelling
emulator_boot and emulator_selector have had since #15, rather than the second
one I had added. Batches keep their per-AVD summary and carry `error` alongside
it, in emulator_delete too so the two destructive scripts agree. Three
"Error: " prefixes embedded in returned messages are gone; the helper adds it.

F4 -- the invented tool output is gone: `emulator -list-avds` and the framed
`emu avd name` reply come from the recordings, the latter with only the AVD
name substituted so the console's OK framing is the recorded article. That
paid off KNOWN_VIOLATIONS' `test_emulator_shutdown.py::_FakeResult` -- the
ratchet caught the stale entry itself. 6 -> 5. No config.ini is recorded on any
profile and none can be captured here, so the read-from-disk test reuses this
file's single existing literal, the one the freeze already covers; recording
one is the next candidate.

F6 -- the census docstring said nine; it is six.

1613 passed on 701ed7d, 1652 passed here.
@fluxxion82
fluxxion82 merged commit 461ba40 into main Sep 3, 2026
3 checks passed
@fluxxion82 fluxxion82 mentioned this pull request Sep 3, 2026
fluxxion82 added a commit that referenced this pull request Sep 3, 2026
* release: v0.7.0

The agent loop is now verified from the skill's own printed output: a test
reads what screen_mapper prints and feeds each printed name back into
navigator, so "implemented but unreachable" fails instead of passing.

Merged for this release:

#8  Inc -1: emulator_shutdown --serial could power off an attached phone, and
    two required checks a docs-only PR cannot satisfy.
#9  Inc 0: the red agent-loop spec and the Quick-Start-equals-e2e guard.
#10 Inc 0: three AST shape guards (quoting, emulator console, bounds), the
    23-mode runtime exit-code sweep, and a call-count floor against a blind
    detector.
#11 Inc 0: both fixture-policy ratchets, so the detector sees hoisted
    constants and hierarchy consumers it used to miss.
#12 Inc 0: recordings on two API levels, a real Gradle JUnit XML, the
    am start -W pair, and the display-override tests.
#13 Inc 1: quote every value that crosses the device shell (all 19 sites), and
    stop reporting a failed lookup as an empty one.
#14 Inc 1: the loop closes on the documented path -- every name screen_mapper
    prints is findable and taps inside the control it names, a bare --tap is
    refused, and --launch waits for the activity.
#15 Inc 1/2: exit codes that mean something, a missing SDK tool that says so
    rather than returning an empty list, the IME guard, and one emulator
    console every adb emu call goes through.
#16 Inc 2: the two destructive lifecycle paths stop guessing -- one tri-state
    "which AVD is this serial" probe for every caller -- and the READMEs stop
    deferring.

Version bumped in the four manifests (SKILL.md frontmatter and its Scripts
heading, plugin.json, marketplace.json, pyproject.toml). SKILL.md's
Status & Roadmap now states what v0.7.0 verifies and the gaps that remain,
and stale test prose describing xfail markers that no longer exist is
corrected (no assertions changed).

* docs(SKILL.md): two overclaims in the v0.7.0 status text

Both found in review, and both are the defect class this release exists to
correct: a status line asserting more than the tests establish.

"Every failing mode exits non-zero and prints {"error": ...} under --json"
welded two claims together, and only the first is verified. The runtime sweep
asserts exit status, never the payload shape. Probing all nineteen --json
modes the sweep drives, under its own fake toolchain: every one exits 1, but
only app_launcher, screen_mapper, anr_watcher, app_state_capture, device_list
and the emulator_* lifecycle scripts print a bare {"error": ...}. snapshot,
sms and container print success and error together; privacy_manager prints
{"success": false, "message": ...}; and navigator, log_monitor, crash_triage
and status_bar refuse at device resolution with prose on stderr and no JSON at
all. The bullet now states the exit-status guarantee, says the sweep does not
assert the shape, and names both sides. "The JSON failure shape is not
uniform" is added under Known gaps.

"checked against bounds resolved independently of the hierarchy" inverted what
the test does. The rectangle is resolved FROM the hierarchy, by the test's own
parser reading the same dump -- independently of navigator, not of the
hierarchy. That is the whole point of the check, so the sentence now says
navigator is checked against the screen rather than against itself.

No script changed. 1652 passed, 1 skipped; Black, Ruff and the plugin
manifest validation are clean.

* docs: narrow three more status claims, and guard the Scripts heading

Review round 2. No script changed.

R1. "A missing SDK tool says so" was true of the five scripts that route
through common/sdk_tools.py and false of emulator_create, which still returns
[] at six sites when avdmanager or sdkmanager is missing or fails, so
--list-devices and --list-images print nothing and exit 0. The bullet now names
the five, notes that device_list keeps a missing avdmanager as a warning
because it only decorates AVDs the emulator already listed, and emulator_create
is a Known gap.

R3. "names every control on the screen" was false: the default report slices
each bucket to BUTTONS_PREVIEW (15) and appends "... (N total)". Now "names up
to 15 controls per bucket in the default report (the full inventory is in
--json)".

R4. tests/test_packaging_contract.py only read SKILL.md's frontmatter, and so
do validate-version.yml and release.yml, so the "## Scripts (vX.Y.Z)" heading
could drift a whole release behind with every guard green. It is now a fifth
parametrized case in the agreement test. Mutation, against a cp snapshot rather
than git: heading set to v0.6.0 fails with "SKILL.md `## Scripts` heading says
0.6.0, pyproject.toml says 0.7.0"; restored from the snapshot, 5 passed.

R2. Confirmed the previous round's wording stands, and extended the Known-gaps
line to name both remaining groups as measured: the {"success": false,
"message": ...} scripts (with snapshot, sms and container emitting both keys),
and navigator, log_monitor, crash_triage and status_bar, which refuse an
unreachable device before any JSON is built and print prose on stderr.

R6. SKILL.md said a known defect "is pinned with xfail(strict=True)"; none is.
Stated as the policy, plus "None is pinned in v0.7.0".

R7. accessibility_audit.py:10-12 now says outright that contrast is not
claimed, so the gap keeps the fact and drops the "despite its own description"
rationale.

R5, stale prose, docstrings and comments only, zero assertions touched:
test_exit_code_sweep.py's header said device_list "still answers" an empty
success (fixed in v0.7.0; the shape survives in emulator_create, which the
sweep does not cover) and its table comment said "the six marked ones" (eight
were marked, none is today); test_quick_start_contract.py described C1 and L3
in the present tense; test_shape_guards.py claimed 47 device-shell calls where
the detector reports 45 -- corrected, and labelled an observation rather than a
contract, since only the floors are asserted -- and two enumeration docstrings
still described the sites they held before L7 and C5/C7 emptied them.

1653 passed, 1 skipped; Black, Ruff and the plugin manifest validation clean.
@fluxxion82
fluxxion82 deleted the fix/inc-2-erase-delete-readme branch September 3, 2026 17:45
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.

1 participant