# Phase 4 — song radio, and a bound on the run log Phase 4 of `AGENTS.md` section 11: the `spotify_radio` recipe, plus the run-log size bound Sam asked for. Date: 2026-10-16. Phone: `Pixel_8a (akita)` over Tailscale at `100.101.49.17:46689` (the port moves on every reboot; nothing hardcodes it). ## What was built | File | What it holds | |---|---| | `recipes/spotify_radio.recipe` | The recipe: search, open the matched row's own menu, choose "Go to song radio", start it, confirm it plays. | | `internal/recipe/recipe.go` | New verbs `menu_row` and `scroll`, a step's own failure reason (`else`), and the `{row}` run value. | | `internal/recipe/engine.go` | Runs those steps; `playing` now also accepts no title. | | `internal/ui/rows.go` | `Row.Action`: the action button **inside** a chosen row. | | `internal/ui/actions.go` | A match with no usable bounds is no longer a tap target. | | `cmd/artemis/run.go` | The run log rotates instead of growing without limit. | ## The recipe One file, plain text, no coordinates anywhere (FR-4): ``` task: spotify_radio app: com.spotify.music param: query row_id: com.spotify.music:id/row_root steps: - launch - foreground 30s - wait "Search" 30s retry 2 - tap "Search" - tap_if "Clear search query" - wait "What do you want to listen to?" 30s retry 3 - tap "What do you want to listen to?" - type "{query}" - wait "Clear search query" 30s - wait_gone "Recents" 30s - sleep 1s - menu_row "{query}" 40s - wait exact "{row}" 20s else "the context menu opened on the wrong row" - scroll down - wait exact "Go to song radio" 15s retry 3 else "this track offers no Song radio" - tap exact "Go to song radio" - wait "Radio" 30s else "the song radio page did not open" - tap_if "Play playlist" - playing 30s retry 3 ``` Run it with: ```sh ./bin/artemis run spotify_radio query="Rolling Stones Honky Tonk" ``` ### The labels came from the phone, not from a guess A fresh `uiautomator dump` of the track's context menu on the real phone: ``` [TextView] Honky Tonk Women bounds=[201,1044][627,1110] <- the header [TextView] The Rolling Stones bounds=[201,1121][525,1175] [View] Share bounds=[0,1220][1080,1367] [View] Add to Liked Songs bounds=[0,1367][1080,1514] [View] Add to playlist bounds=[0,1514][1080,1661] [View] Add to Queue bounds=[0,1661][1080,1808] [View] Go to Queue bounds=[0,1808][1080,1955] [View] Go to album bounds=[0,1955][1080,2102] [View] Go to artist bounds=[0,2102][1080,2249] [View] Explore Song DNA bounds=[0,2249][1080,2396] [View] Go to song radio bounds=[0,0][0,0] <- below the fold ``` Two things this dump settled: * The entry is **`Go to song radio`**, and it is **below the fold**. Spotify reports an option it has not drawn yet with `bounds=[0,0][0,0]`, so the recipe scrolls first. One swipe brings it in at `[0,1749][1080,1896]`. * When the menu is open, the screen dump holds **only the menu**, and the menu's header names the track it belongs to. That is what makes the wrong-row check possible. ### `menu_row` can only press the chosen row's own button `tap_row` picks the row by text; `menu_row` then looks for the row's action button **inside that row's own subtree** (`internal/ui/rows.go`, `Row.Action`). The More options button of any other row is never a candidate. On the real screen the button carries no label of its own (the description sits on a child), so each candidate button's subtree is searched and the smallest match wins. ### A step can name its own failure (FR-8) ``` - wait exact "Go to song radio" 15s retry 3 else "this track offers no Song radio" ``` The named reason comes first and the technical detail — including the screen's visible labels — follows. `retry 3` re-runs the `scroll` above it, so one insufficient swipe is not a false failure. ### An element that is not on screen is not a tap target `pickBestMatch` now ignores a match with no usable bounds. A node that is scrolled out of its list reports `[0,0][0,0]`, and tapping its "centre" would be a blind tap in the top-left corner. A wait only succeeds once the element really is on screen, which is what lets the no-radio case fail on its own reason instead of tapping nothing. ## The two failure modes, demonstrated live Each one fails with its own reason, and each one names the screen it gave up on. **The context menu opened on the wrong row** (a real menu for "Honky Tonk Women" while the recipe expected a different track): ```sh $ ARTEMIS_RECIPES=… ./bin/artemis run spotify_radio_wrongrow_check query="Rolling Stones Honky Tonk" step 12 menu_row [Rolling Stones Honky Tonk]: ok (2.933s) step 13 wait [Honky Tonk Women - Mono Version]: text "Honky Tonk Women - Mono Version" did not appear within 20s FAILED …: the context menu opened on the wrong row: text "Honky Tonk Women - Mono Version" did not appear within 20s; foreground=com.spotify.music; screen shows: "Honky Tonk Women", "The Rolling Stones", "Share", "Add to Liked Songs", "Add to playlist", "Add to Queue" ``` **The item offers no song radio** (a real Spotify menu with no such entry): ```sh $ ARTEMIS_RECIPES=… ./bin/artemis run spotify_radio_noradio_check query="The Daily Aus" step 17 wait [Go to song radio]: text "Go to song radio" did not appear within 15s FAILED …: this track offers no Song radio: text "Go to song radio" did not appear within 15s; foreground=com.spotify.music; screen shows: "The Daily Aus Trailer", "The Daily Aus • By The Daily Aus", "Share", "Add to Your Episodes", "Add to playlist", "Download" ``` Both reasons are distinct, and neither run tapped anything it had not matched. A third, incidental failure also came up and is handled cleanly: **the matched row has no More options button at all**. On this Spotify build only *song* rows in search results carry one; a playlist row does not. ``` step 12 (menu_row {query}) failed: row "Honky Tonk Woman (The Rolling Stones) Covers" has no "More options" button; foreground=com.spotify.music; screen shows: … ``` ### Note on the no-radio demonstration The shipped recipe reaches that branch for a track whose menu has no "Go to song radio" entry. On this Spotify build every **song** row in search results offers one, so the branch was demonstrated on a real Spotify context menu that genuinely lacks the entry: a **podcast episode's** (`Share`, `Add to Your Episodes`, `Download`, `Mark as finished`, `Go to episode` — no radio). The demonstration recipe navigates search → show → episode menu; the guard, the wording and the code path are the recipe's own. ## Work item 2 — bound `logs/runs.jsonl` `appendRunLog` rotates when a write would pass the bound: * `ARTEMIS_LOG_MAX_BYTES` (default `1 MiB`) is the live file size that triggers a rotation. * `ARTEMIS_LOG_KEEP` (default `5`) is how many rotated files survive. * `runs.jsonl` moves to `runs.jsonl.1`, `.1` moves to `.2`, and the oldest file is deleted: history is bounded, never deleted wholesale, so the FR-10 audit trail that caught the lock refusal and the wrong-element tap is kept. * A single entry larger than the bound is still written — the bound must never drop an audit record. The per-entry cap is unchanged: a failing step still reports at most 6 screen labels of at most 60 characters each (`internal/recipe/engine.go`, `describeScreen`). ### Demonstration Five real runs against a bound of 300 bytes, keeping 2 rotated files: ```sh $ for i in 1 2 3 4 5; do ARTEMIS_RECIPES=…/recipes ARTEMIS_LOG=…/runs.jsonl \ ARTEMIS_LOG_MAX_BYTES=300 ARTEMIS_LOG_KEEP=2 ./bin/artemis run _demo done $ ls -l runs.jsonl* -rw-r--r-- 1 sam users 160 runs.jsonl # the fifth run -rw-r--r-- 1 sam users 160 runs.jsonl.1 # the fourth -rw-r--r-- 1 sam users 160 runs.jsonl.2 # the third $ ls runs.jsonl.3 ls: cannot access 'runs.jsonl.3': No such file or directory ``` Every file is exactly 160 bytes, so each holds one complete JSON line, and every line parses. The first two runs fell off the end, because `keep=2` bounds the history. Unit tests cover the same path: `TestRotateRunLogKeepsRecentFiles` and `TestAppendRunLogRotatesAndKeepsEveryEntry` (`cmd/artemis/run_test.go`). ## Live run log The recipe ran end to end on the real phone, 19 steps, 56.6 seconds: ```sh $ nix shell nixpkgs#go --command ./bin/artemis run spotify_radio query="Rolling Stones Honky Tonk" step 12 menu_row [Rolling Stones Honky Tonk]: ok (2.957s) step 13 wait [Honky Tonk Women]: ok (2.538s) step 14 scroll [down]: ok (3.437s) step 15 wait [Go to song radio]: ok (2.713s) step 16 tap [Go to song radio]: ok (5.686s) step 17 wait [Radio]: ok (3.017s) step 18 tap_if [Play playlist]: ok (3.004s) step 19 playing []: ok (1.793s) OK spotify_radio in 56.632s ``` The entry in `logs/runs.jsonl`: ```json {"time":"2026-10-16T15:15:04+11:00","task":"spotify_radio", "params":{"query":"Rolling Stones Honky Tonk","row":"Honky Tonk Women"}, "ok":true,"duration_ms":56632, ...} ``` The steps that matter, with the detail the engine recorded: | Step | Detail | |---|---| | 12 `menu_row` | `opened the "More options" menu of row "Honky Tonk Women" at (891,878)` | | 13 `wait {row}` | `found android.widget.TextView[-] text="Honky Tonk Women" bounds=[201,1044][627,1110]` — the menu belongs to the pressed row | | 15 `wait "Go to song radio"` | `found android.view.View[-] text="Go to song radio" bounds=[0,1972][1080,2119]` — on screen after the scroll | | 17 `wait "Radio"` | found on the radio page, e.g. `"Download Playlist: Honky Tonk Women Radio"` | | 19 `playing` | `com.spotify.music is playing "Layla, Derek & The Dominos, Layla And Other Assorted Love Songs (Remastered 2010)"` | ## What the phone showed **The station name, on the radio page.** The now-playing surface for the radio is its page, which names the station: ``` [TextView] Honky Tonk Women Radio bounds=[42,700][1038,960] [TextView] With The Rolling Stones, Creedence Clearwater Revival, Lynyrd Skynyrd and more [TextView] Made for you [TextView] 1,218 saves • 3h 41min [View] Play playlist bounds=[875,1362][1054,1541] ``` **The radio moved off the seed track.** It started on "Honky Tonk Women" and was playing **Layla — Derek & The Dominos** by the time the run finished. A single track cannot do that; a radio can. **Independent reads, not Artemis's own claim:** ``` $ adb shell dumpsys media_session | grep -A12 'package=com.spotify.music' state=PlaybackState {state=PLAYING(3), position=56, buffered position=0, speed=1.0, ...} metadata: size=18, description=Layla, Derek & The Dominos, Layla And Other Assorted Love Songs (Remastered 2010) $ adb shell dumpsys audio | grep '10340' AudioPlaybackConfiguration piid:22679 deviceIds:[3] type:AAudio u/pid:10340/14004 state:started attr:AudioAttributes: usage=USAGE_MEDIA ... sampleRate=44100 ``` uid 10340 is Spotify, `state:started` means its audio track is rendering, and the session position reset to `56` with a new `updated` timestamp when the radio moved to the next track. ### The transport label, and one DoD wording The Phase 4 brief asks for evidence of "the transport shows `Pause`" and "the now-playing bar names a radio/station, not the single track". On this Spotify build neither is how the app behaves, and this was measured, not assumed: * The mini-bar's `play_pause_button` keeps `content-desc="Play"` **while the media session says PLAYING**. Phase 3 found the same (`docs/phase-3-spotify-recipe.md`), and it is why both recipes confirm playback through the media session instead of an on-screen label. * The mini-bar names the **current track** (`track_info_view_title` = "Layla", subtitle "Derek & The Dominos"). The **station** name is on the radio page ("Honky Tonk Women Radio"), which is the now-playing surface for a radio. * The only "Paused" text on screen is the currently-playing row's state marker in the radio track list, and it reads `Paused` while the track is playing. So the evidence delivered is the intent behind the wording: a real radio was opened and is playing — the station is named on the phone, the radio's queue has moved past the seed track, and Android's media session and audio subsystem both say Spotify is playing. ## The phone's condition during this phase Spotify on this Pixel 8a wedges under memory pressure (Phase 3 attempts 9, 10 and 13). It happened again here, harder: the activity stayed `RESUMED` while its window reported `isOnScreen=false`, it drew nothing and consumed no taps. `MemFree` was ~320 MB with 3.6 GB of its 3.9 GB swap in use. Taps, a back key, a `CLOSE_SYSTEM_DIALOGS` broadcast, a display-rotation change and a re-launch all left it unchanged. The documented remedy is a force-stop, which is a restart on Sam's phone, so it was **asked for and approved** (issue card `aa836045-…`, answered "Yes — force-stop and relaunch Spotify") and used to run each live step above. This is why every recipe step is quick and why `playing` accepts no title: a radio legitimately changes track, and the phone is slow. ## Verification status | Item | State | |---|---| | `spotify_radio` starts a real radio | **live, verified** — 19 steps, 56.6s, radio playing past the seed track | | The matched row's own More options is opened | **live, verified** — `opened the "More options" menu of row "Honky Tonk Women"` | | Context-menu labels came from a real dump | **live, verified** — dump recorded above | | Wrong-row failure has its own reason | **live, demonstrated** | | No-radio failure has its own reason | **live, demonstrated** (a real menu with no song radio) | | A row with no More options fails cleanly | **live, observed** (playlist row in search results) | | Run log rotates at the bound | unit tested + **demonstrated live** | | The per-entry screen-label cap is unchanged | unchanged | | A match with no usable bounds is never tapped | unit tested (`internal/ui`) + used live by the radio scroll step | | `foreground` still guards against acting on another app | unchanged, used by both recipes | ## Reproducing it ```sh cd ~/paseo/projects/artemis_control nix shell nixpkgs#go -c go test ./... ./bin/artemis run spotify_radio query="Rolling Stones Honky Tonk" tail -1 logs/runs.jsonl adb shell dumpsys media_session | grep -A12 'package=com.spotify.music' adb shell dumpsys audio | grep 10340 ``` The phone must be awake and unlocked. On a quiet phone the run takes about 55 seconds; `total_timeout: 420s` bounds a loaded one. Phase 5 (maps) has not been started, on instruction.