Skip to content

fix: make unknown outcomes representable instead of success-shaped - #30

Merged
abrichr merged 1 commit into
mainfrom
fix/honest-failure-sweep
Jul 28, 2026
Merged

fix: make unknown outcomes representable instead of success-shaped#30
abrichr merged 1 commit into
mainfrom
fix/honest-failure-sweep

Conversation

@abrichr

@abrichr abrichr commented Jul 28, 2026

Copy link
Copy Markdown
Member

What this is

A sweep of openadapt-tray for one failure class: a failure rendered as a successful empty result. return [] when we could not look. A count of 0 that means "the body was unreadable". A False that means "we never asked the user". A bool that means "we did not check".

The judgement rule applied throughout: where a function could not distinguish "no result" from "could not run", the fix makes the distinction representable — a raised exception, a returned reason, a different rendering. Adding a log line above the same wrong value is not a fix.

Every fix is mutation-checked: the production change was reverted, the new test was confirmed to FAIL, and the fix restored.

Verifying PR #29

Both defects #29 reported are real and the repairs hold. Mutation-checked against b3caac7:

Test Reverted to Result
test_failed_toast_reports_failure notifications.py@b3caac7 FAILS: assert True is False
test_absent_entry_is_success keychain.py@b3caac7 FAILS: assert False is True

test_returncode_drives_result (Linux) passes against pre-#29 code — _show_linux always checked returncode, so it is a guard test, not a regression test. Reported as such rather than strengthened against a defect that does not exist.

#29's toast fix was incomplete at the system level, and this PR finishes it: _show_windows was made to return an honest False, but its only caller — HostedPoller._fire_notification — discarded it, and _last_count advanced regardless. The user was recorded as informed about breaks they were never shown, and the count had to RISE again before a second attempt.

Defects fixed

hosted.py

  • CountResult.from_payload invented an all-clear. payload.get("count", 0) turned any body that lost or renamed count into "nothing needs attention" — on the one number the tray exists to report. Now raises InvalidCountPayload; the badge keeps its last known value rather than clearing to zero.
  • The notification-delivery boolean was discarded (above). Delivery is now propagated; _last_count only advances when the notification landed, so the next poll retries.
  • route_break_click discarded webbrowser.open's result. A click that opened nothing now returns False and open_needs_attention tells the user.

config.py

  • An unreadable tray.json silently became "default settings". deployment_lane defaults to "cloud", so a byoc install — where the fix must stay local — was moved onto the hosted route by a corrupt settings file, with only a printed warning. A missing file still means defaults; an unreadable one raises ConfigLoadError, and the tray says so on startup.

menu.py

  • _get_recent_captures returned [] on any exception, so an unreadable captures directory rendered the same reassuring "No captures" as an empty one. Now raises; the menu says "Could not read captures".
  • _view_capture never read openadapt visualize's exit status, so a CLI that ran and failed did nothing at all and said nothing. Non-zero now falls back to the file browser, like a missing CLI always did.

platform/

  • "We never asked" was indistinguishable from the user's answer. prompt_input returned None (= "cancelled") and confirm_dialog returned False (= "no") when no dialog could be shown at all — so Start Recording or Delete could do nothing, forever, silently. Both now raise DialogUnavailableError.
  • Linux: zenity/kdialog exit 1 = the user declined; any other non-zero is the tool failing, which used to be read as the user's answer and skipped the remaining fallbacks.
  • macOS: an osascript execution error is now separated from a user cancel via stderr.
  • Callers: a recording proceeds with a default name (and says why there was no prompt); a delete is not performed and the user is told it was not performed.

Guards that could not fire

No || true, continue-on-error or exit 0 anywhere in .github/workflows/. Two real findings:

  • ruff check src tests scripts left examples/ and the four root-level helper scripts outside every gate — 15 findings sat there unreported while CI showed a green lint. The gate now covers the repository and those findings are fixed, including generate_screenshots.py printing a success tick regardless of whether the notification was delivered (the same failure class, in the tooling).
  • The gitleaks install piped curl into tar without pipefail, so the step reported tar's status rather than the download's. Now set -euo pipefail + curl --fail.

Candidates judged NOT to be defects

  • _show_linux / _show_macos — already derive their result from returncode.
  • pystray.Icon.notify() returning True — pystray's API is fire-and-forget with no delivery signal; there is nothing to derive a checked outcome from.
  • icons._load_icon / _load_master — degrade to a correctly rendered fallback icon, not to an empty result, and log since fix: report keychain, toast and IPC failures honestly; pin ruff to 0.16.x #29.
  • app._launch_desktop_app — the unchecked Popen is covered by ensure_desktop_connection, which polls and returns False on timeout. The failure is already representable.
  • poll_once returning None for every failure kind — the set_offline(True) flag already makes "could not look" representable to the UI and distinct from a count of zero.
  • ipc._handle_message / _process_message unknown message type, and _on_ipc_status_update's except KeyError: pass — event sinks with no return channel; the only available change is a log line, which is not a fix. Rendering the last known good state is the designed degradation.
  • scripts/generate_icons.py / check_release_consistency.pycheck=True and parser.error() mean their success values are derived from checked outcomes.

Verification

uv sync --locked --extra dev, uv run ruff check ., uv run pytest tests -q (198 passed, 2 skipped), uv build — all clean locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM

Every defect here is one failure class: a failure rendered as a successful
empty result. `return []` when we could not look. A `count` of 0 that means
"the body was unreadable". A `False` that means "we never asked the user". A
bool that means "we did not check". Each fix makes the distinction
representable -- a raised exception, a returned reason, a different rendering
-- rather than adding a log line above the same wrong value.

Every fix below is mutation-checked: the production change was reverted, the
new test was confirmed to FAIL, and the fix restored.

hosted.py

* `CountResult.from_payload` used `payload.get("count", 0)`, so a response body
  that lost or renamed `count` produced a confident all-clear on the one number
  the tray exists to report. It now raises `InvalidCountPayload`, `poll_once`
  reports a failed poll, and the badge keeps its last known value instead of
  clearing to zero. The display-only subfields stay tolerant of absence.

* `_fire_notification` discarded the notifier's return value and `_handle_result`
  advanced `_last_count` regardless, so the honest `False` that #29 added to
  `_show_windows` went nowhere: a user was recorded as informed about breaks
  they were never shown, and the count had to RISE again before a second
  attempt. Delivery is now propagated and `_last_count` only advances when the
  notification actually landed, so the next poll retries.

* `route_break_click` discarded `webbrowser.open`'s result. A click that opened
  nothing now returns False, and `TrayApplication.open_needs_attention` tells
  the user instead of leaving them in front of an unchanged screen.

config.py

* `TrayConfig.load` printed a warning and returned defaults when tray.json was
  unreadable. `deployment_lane` defaults to "cloud", so a byoc install -- where
  the fix must stay local -- was silently moved onto the hosted route by a
  corrupt settings file. A missing file still means defaults; an unreadable one
  raises `ConfigLoadError`. `load_or_defaults` hands back both so the caller
  cannot lose it, and the tray now says so on startup.

menu.py

* `_get_recent_captures` swallowed every exception and returned `[]`, so an
  unreadable captures directory rendered the same "No captures" as an empty
  one. It now raises, and the menu says "Could not read captures".

* `_view_capture` never read the exit status of `openadapt visualize`, so a CLI
  that ran and failed did nothing at all and said nothing. A non-zero exit now
  falls back to the file browser, like a missing CLI always did.

platform/

* `prompt_input` returned None and `confirm_dialog` returned False when no
  dialog could be shown -- identical to "the user cancelled" and "the user said
  no". Clicking Start Recording or Delete could therefore do nothing, forever,
  silently. Both now raise `DialogUnavailableError` when nothing could ask, and
  the answers they do return are answers the user actually gave.
  On Linux, zenity/kdialog exit 1 means the user declined; any other non-zero
  exit is the tool failing, which used to be read as the user's answer AND
  skipped the remaining fallbacks. On macOS, an osascript execution error is
  now separated from a user cancel by stderr.
  Callers: a recording proceeds with a default name (and says why there was no
  prompt); a delete is not performed and the user is told it was not performed.

CI

* `ruff check src tests scripts` left examples/ and the four root-level helper
  scripts outside every gate: 15 findings sat there while CI reported a green
  lint. The gate now covers the repository and those findings are fixed --
  including a screenshot helper that printed a success tick regardless of
  whether the notification was delivered.
* The gitleaks install piped curl into tar without pipefail, so the step
  reported tar's status rather than the download's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
@abrichr
abrichr merged commit 3f78475 into main Jul 28, 2026
10 checks passed
@abrichr
abrichr deleted the fix/honest-failure-sweep branch July 28, 2026 03:23
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