Skip to content

fix(sound): classify Play/Stop Sound failures instead of collapsing them into False - #1262

Merged
jleinenbach merged 7 commits into
1.7from
fix/sound-failure-classification
Aug 26, 2026
Merged

fix(sound): classify Play/Stop Sound failures instead of collapsing them into False#1262
jleinenbach merged 7 commits into
1.7from
fix/sound-failure-classification

Conversation

@jleinenbach

@jleinenbach jleinenbach commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Draft. AP-1 to AP-3 are done; AP-4 through AP-8 will land on this branch.
Opened early so CI and review have a channel while the rest is built.

Background

Triage of an upstream report (BSkando#215) turned up nothing that
applies to 1.7 -- that TypeError was fixed here long ago. It did, however,
prompt a look at what 1.7 does when a Play or Stop does fail, and three real
defects fell out of that. All three were proven by a red test against 97edca64
before anything was planned.

The common root: api.async_play_sound and api.async_stop_sound catch every
exception and return (False, None). Auth rejection, HTTP 5xx, read timeout and
a local TypeError all arrive at the coordinator as the same False, and the
coordinator has to guess what happened. It currently guesses "push transport
problem", which arms a 90-second cooldown, sets FcmStatus.DEGRADED and makes
can_play_sound() report False for the duration.

In this PR so far

AP-1 -- the auth-failure state is no longer cleared by a failed play

_set_auth_state(failed=False) ran unconditionally in the play path, so a play
rejected by an expired sign-in erased the very signal that the sign-in had
expired. async_stop_sound has always applied the opposite rule and says why in
a comment; the two paths now agree.

A failed play still does not set the auth-failure state and still does not
trigger reauth -- see the commit body for why that is deliberate and not an
oversight.

Two tests, the negative one verified red first (exit 1, Expected 'mock' to not have been called. Called 1 times. Calls: [call(failed=False)]). 81 passed across
the three affected test files, 79 before. ruff and mypy --strict clean on the
changed module.

AP-2 -- the dispatch outcome types

SoundDispatchOutcome (seven values, only TRANSPORT_FAILED justifies a
cooldown) and PlaySoundResult (outcome plus cancel key, two deliberately
independent facts) live next to the existing StopSoundOutcome in const.py.
Additive; nothing reads them in this commit.

One test-infrastructure defect surfaced here and is fixed in the same commit:
tests/helpers/constants.py loads const.py via spec_from_file_location /
exec_module without registering the module in sys.modules. That is half of
the documented importlib recipe, and it only matters once the module resolves
its own annotations at class-creation time -- which @dataclass does. Without
the fix, adding any dataclass to const.py breaks tests/conftest.py with
AttributeError: 'NoneType' object has no attribute '__dict__', i.e. a
collection error for the whole suite.

AP-3 -- every exit names its own cause

async_play_sound returns PlaySoundResult, async_stop_sound returns
SoundDispatchOutcome. Two placements are new rather than renamed:

  • NovaLogicError and NovaProtobufDecodeError were caught by except NovaError and would now be reported as transport failures. They are caught
    before it, as REJECTED_SERVER and INTERNAL_ERROR.
  • async_stop_sound had no except NovaError at all; a network failure after
    exhausted retries fell through to except Exception. The branch is added, so
    the stop path can carry the same table as the play path.

coordinator/locate.py is adapted minimally and on purpose: a tuple unpack does
not work on a dataclass, and a StrEnum is always truthy, so if not submitted
would have read every rejected stop as a success. It reads play.accepted /
play.cancel_key and stop_outcome is ACCEPTED -- exactly the values the old
contract carried. Acting on the classification is AP-4, not this commit.

The test stubs move on the same rule: the old False maps to
TRANSPORT_FAILED, because today every non-acceptance arms the cooldown, so
each existing test keeps asserting the same behaviour as before.

Verified red first (exit 1, nine failures, AttributeError: 'tuple' object has no attribute 'outcome'). tests/test_api_basics.py 152 -> 168 tests, green.
mypy --strict on the three changed modules clean, ruff check and ruff format --check clean tree-wide. Changed-code coverage measured against the
diff: 207 changed lines in api.py, 20 in locate.py, none uncovered.

AP-4 (commit dd950f0b): the coordinator reads the classification

Reported by Codex on 72e4602f: the conversion back to accepted/cancel_key
threw the classification away and the following if not ok still armed the
cooldown for every non-acceptance, contradicting const.py ("This is the ONLY
outcome that justifies arming the push cooldown") and therefore Rule §9.DOC.
The contract was stated but not enforced at its only consumer.

Both sound paths now branch on outcome. The blanket except Exception
handlers stop arming the cooldown too, because nothing that reaches them came
from the push transport: api.py classifies every unexpected exception in band
and returns INTERNAL_ERROR instead of raising, the only code before its try
is the fully guarded _get_fcm_token_for_action(), and _async_save_sound_uuids
swallows its own errors. The two typed handlers for
TimeoutError/ClientConnectionError/ClientError keep the cooldown, since
api is a Protocol and an unwrapped aiohttp error there really is a transport
failure.

Red probe before the fix: 12 failures against the unchanged production code; the
four cases that were already correct stayed green.

Still to come on this branch

AP-5 Done (fd399eba): a stop is no longer suppressed by the cooldown its own play just armed
AP-6 Migrate the tests that pin the current (False, None) contract, including the one added in AP-1
AP-7 Docs and invariant anchors
AP-8 Optional, deferred: the coordinator/service boundary and its user-facing strings

AP-8 is optional and may be dropped.

Review notes

  • AP-1 is reviewable and mergeable on its own. If the rest stalls, it should not
    be held hostage to it.
  • AP-2 and AP-3 change no observable behaviour by design. The one thing worth
    reviewing closely is whether the mapping in api.py matches what each
    exception really means, since AP-4 will act on it.
  • The measured blast radius of the contract change (AP-2 to AP-4) is 156
    occurrences across eleven test files, which is why it is staged rather than
    done in one commit.
  • AP-4 is the first commit here that changes observable behaviour. What to check
    is the decision table, not the diff shape: tests/test_coordinator_locate_basics.py
    carries one row per SoundDispatchOutcome member for each path, and two guard
    tests pin those tables to set(SoundDispatchOutcome).

AP-5 (fd399eba)

A play that reaches the wire and then loses the answer stores a cancel key and
arms the 90-second push cooldown in the same breath. _api_push_ready()
short-circuits on that cooldown, so async_stop_sound suppressed the stop that
key exists for during the first 90 seconds after the play -- exactly when a user
reaches for the Stop button.

The readiness gate now passes in one further case: a window is running and
the stop would be correlated (our own cached key, still fresh). Anything that
could at best report UNCORRELATED stays suppressed.

What to review closely:

  • _stop_would_be_correlated() is the single definition behind two decisions
    that must not drift: may this stop break the window, and may an accepted stop
    spend the cached key. Two tests make the predicate disagree with the raw cache
    state, so an inline re-derivation at either site cannot pass them.
  • _note_stop_transport_problem_without_extending() exists because the
    exception must not feed itself. _note_push_transport_problem() sets
    _push_cooldown_until absolutely and the stop button has no availability
    guard, so an unguarded call would let repeated presses push the end of the
    window forward and keep manual locate disabled meanwhile. The failure is still
    reported in full -- the DEGRADED flag is not lost -- only a window that was
    already running is restored rather than restarted.
  • The price is a changed outcome class: a stop that used to end as SUPPRESSED
    now reaches the transport and can end as FAILED, which services.py reports
    with a different translation key. There is a test for exactly that.

Known and deliberately not in this commit: the play path arms the same
cooldown without the guard, and can_play_sound() returns early on a cached
can_ring capability before it looks at the window. That predates this change;
docs/PLAY_SOUND_ARCHITECTURE.md records it as a follow-up.

`async_play_sound` ran `_set_auth_state(failed=False)` unconditionally, on the
failure path as well. `api.async_play_sound` collapses `NovaAuthError` and HTTP
401/403 into the same `(False, None)` it returns for a read timeout, so a play
rejected by an expired sign-in did not merely fail: it erased the very signal
that the sign-in had expired, and did so on every press for as long as the
credentials stayed bad.

The sister path already applies the opposite rule and states the reason in a
comment (`async_stop_sound`: "No credential proof on this path"). The two paths
now agree; the fix is the `else:` that was missing.

Scope, so the change is not read as more than it is: a failed play still does
not SET the auth-failure state, and still does not trigger reauth. It only stops
clearing it. Setting it would raise a Repairs issue and fire an event off a
`NovaAuthError` that its own docstring calls possibly transient, which would
trade one false signal for another.

Tests: `test_failed_play_does_not_clear_auth_state` was written first and
verified red against 97edca6 -- exit 1 with `Expected 'mock' to not have been
called. Called 1 times. Calls: [call(failed=False)]`, i.e. the defect itself and
not a missing file (exit 4) or an unknown test name (exit 5).
`test_accepted_play_still_clears_auth_state` guards the positive half and was
green before and after. 81 passed across
`test_coordinator_locate_basics.py`, `test_coordinator_sound_uuid.py` and
`test_stop_sound_correlation.py` (79 before).

Known forward breakage, recorded rather than discovered later: the new negative
test pins today's `(False, None)` contract of `api.async_play_sound`. Replacing
that contract with a typed dispatch outcome will break it by design, and its
migration is part of that work.
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@jleinenbach

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 9b3fb8557e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

api.py already reaches nine distinct exits for a Play Sound command
(missing action token, empty submitter reply, HTTP 200, NovaAuthError,
HTTP 401/403, other HTTP status, rate limit, network error, unexpected
exception), and all of them collapse into a single False at the boundary.
The coordinator has no choice but to read that False as "the push
transport is broken", so a server saying no, an expired sign-in and a bug
of our own each arm the same 90-second cooldown and the same
FcmStatus.DEGRADED. This is the failure class StopSoundOutcome already
documents one layer up: a bool cannot carry the state space.

Add SoundDispatchOutcome (seven values, only TRANSPORT_FAILED justifies a
cooldown) and PlaySoundResult (outcome plus cancel key, deliberately two
independent facts). Nothing consumes them yet; this commit is additive and
changes no behaviour.

Also fix tests/helpers/constants.py, which loads const.py through
spec_from_file_location/exec_module without registering the module in
sys.modules -- the half of the documented importlib recipe that only
matters once the module resolves its own annotations at class-creation
time. @DataClass does exactly that: with `from __future__ import
annotations` every field annotation is a string, and dataclasses resolves
it via sys.modules[cls.__module__] to tell a KW_ONLY/ClassVar marker from
a real field. Unregistered, that lookup returns None and const.py fails to
import with

    AttributeError: 'NoneType' object has no attribute '__dict__'

in tests/conftest.py:375, i.e. as a collection error for the whole suite.
Without this, const.py cannot host a dataclass at all.

Verified: tests/test_sound_dispatch_contract.py fails to collect before the
type exists (exit 2, ImportError: cannot import name 'PlaySoundResult') and
passes after; mypy --strict and ruff check/format clean; the six sound-
related test files stay green (173 passed).
async_play_sound now returns PlaySoundResult and async_stop_sound returns
SoundDispatchOutcome, so each exit names who refused instead of collapsing
into False: NOT_SENT for a missing local token, REJECTED_AUTH / _SERVER /
_RATE_LIMIT for a server that answered and refused, TRANSPORT_FAILED for a
network that never gave a usable answer, INTERNAL_ERROR for our own defect
or a broken submitter contract.

Two of those are new placements rather than renamings. NovaLogicError and
NovaProtobufDecodeError were caught by `except NovaError` and would now be
reported as transport failures; they are caught before it, as
REJECTED_SERVER (the server answered with an error code in a 200 body) and
INTERNAL_ERROR (we could not decode what the transport delivered). And
async_stop_sound had no `except NovaError` at all -- a network failure
after exhausted retries fell through to `except Exception` -- so the
branch is added, which is what lets the stop path carry the same table as
the play path. Both `except Exception` handlers now log with a traceback:
a bug of our own deserves one, and it is no longer indistinguishable from
an outage.

coordinator/locate.py is adapted minimally and on purpose. A tuple unpack
does not work on a dataclass, and a StrEnum is always truthy, so `if not
submitted` would have read every rejected stop as a success. It therefore
reads play.accepted / play.cancel_key and `stop_outcome is ACCEPTED`, which
are exactly the values the old contract carried. Acting on the
classification -- above all, arming the push cooldown only for
TRANSPORT_FAILED -- is deliberately not part of this commit.

The test stubs move with the contract on the same rule: the old False maps
to TRANSPORT_FAILED, because today every non-acceptance arms the cooldown,
so each existing test keeps asserting the same behaviour as before.

docs/PLAY_SOUND_ARCHITECTURE.md defines IRR-CA-SOUND-FAILURE-CLASS, the
anchor api.py now cites; leaving the reference dangling is the defect
tests/test_stop_sound_correlation.py already guards against.

Verified: test_play_sound_classifies_every_exit fails before the change
(exit 1, nine failures, AttributeError: 'tuple' object has no attribute
'outcome') and passes after; tests/test_api_basics.py green (152 -> 168);
mypy --strict on api.py, const.py and coordinator/locate.py clean; ruff
check and ruff format --check clean tree-wide; changed-code coverage
measured against the diff: 207 changed lines in api.py and 20 in locate.py,
none uncovered. Full suite 6605 tests, 6584 passed, 19 skipped, 2 failed --
both in tests/test_main.py, both failing on the untouched parent commit as
well (subprocess tests whose child is SIGKILLed after a 30s timeout in this
environment).
@jleinenbach

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 72e4602ff2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# part of this one. Until then the two facts are unpacked to exactly
# the values the old tuple carried, so this changes the type at the
# boundary and nothing else.
ok, request_uuid = play.accepted, play.cancel_key

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the dispatch outcome through the coordinator

When Play Sound returns REJECTED_AUTH, REJECTED_RATE_LIMIT, REJECTED_SERVER, NOT_SENT, or INTERNAL_ERROR, this conversion discards the classification and the subsequent if not ok still calls _note_push_transport_problem(); the Stop Sound path repeats the same collapse. Those non-transport failures therefore continue to set FcmStatus.DEGRADED, arm the 90-second cooldown, and disable sound controls, contradicting the new contract and documentation that only TRANSPORT_FAILED may do so. Branch on the outcome and call the cooldown helper only for TRANSPORT_FAILED.

AGENTS.md reference: AGENTS.md:L627-L629

Useful? React with 👍 / 👎.

`SoundDispatchOutcome` documents that only `TRANSPORT_FAILED` justifies
arming the push cooldown (const.py, "This is the ONLY outcome that
justifies arming the push cooldown"). The coordinator did not act on it:
it unpacked the result back down to `accepted`/`cancel_key` and kept
deciding on `if not ok`, so a 401/403, a 429, a missing local action
token and a bug of our own each still armed the 90-second cooldown,
flipped the integration to `FcmStatus.DEGRADED` and made
`can_play_sound()` report the button as unavailable -- an outage the
integration inflicted on itself over a network that was working, and one
that then also suppressed the user's follow-up Stop. Contract stated,
contract not enforced, which is what AGENTS.md Rule §9.DOC forbids.

Both sound paths now read `outcome`. The blanket `except Exception`
handlers stop arming the cooldown as well: `api.async_play_sound` and
`api.async_stop_sound` classify every unexpected exception in band and
return `INTERNAL_ERROR` rather than raising, the only code before their
`try` is the fully guarded `_get_fcm_token_for_action()`, and the one
`await` after the call inside the coordinator's own `try`
(`_async_save_sound_uuids`) swallows its own errors. Nothing that
reaches those handlers came from the push transport. The two typed
handlers for `TimeoutError`/`ClientConnectionError`/`ClientError` keep
the cooldown on purpose: `api` is a Protocol, and an aiohttp error from
an implementation that does not wrap it really is a transport failure.

Red probe before the fix: 12 failures against the unchanged production
code, `assert True is False` on `_note_push_transport_problem.called`
for `rejected_auth`, `rejected_rate_limit`, `rejected_server`,
`not_sent` and `internal_error` on both paths, plus
`Expected 'mock' to not have been called` in the two exception tests.
The four cases that were already correct (`accepted`, `transport_failed`
per path) stayed green, so the red is the defect and not a missing test
name.

The two case tables carry one row per enum member and are pinned to
`set(SoundDispatchOutcome)` by their own guard tests, so a member added
later cannot silently inherit whatever the cascade happens to do.

Reported by Codex on 72e4602.
@jleinenbach

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: dd950f0bc7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

A play that reaches the wire and then loses the answer stores a cancel
key and arms the 90-second push cooldown in the same breath. Because
_api_push_ready() short-circuits on that cooldown, async_stop_sound
suppressed the stop the key exists for during the first 90 seconds after
the play -- exactly when a user reaches for the Stop button.

The readiness gate now passes in one further case: a window is running
AND the stop would be correlated, meaning it carries our own cached key
and that key is still fresh. Anything that could at best report
UNCORRELATED (no key, an aged key, a foreign key supplied by the caller)
stays suppressed, so the exception only ever buys an attempt that can
actually silence the device.

Two things keep it honest:

- _stop_would_be_correlated() is the single definition of "correlated"
  behind both this gate and the decision to spend the cached key. A
  second inline derivation at either site is the drift the extraction
  exists to prevent, and two tests pin both sites to the predicate.
- _note_stop_transport_problem_without_extending() makes sure the
  exception cannot feed itself. _note_push_transport_problem() sets
  _push_cooldown_until absolutely, and the stop button has no
  availability guard, so an unguarded call would let repeated presses
  push the end of the window forward and keep manual locate disabled for
  as long as the pressing goes on. The failure is still reported in full
  (the DEGRADED flag is not lost); only a window that was already
  running is restored instead of restarted.

The price is a changed outcome class, not a changed guarantee: a stop
that used to end as SUPPRESSED now reaches the transport and can end as
FAILED, which services.py reports with a different translation key. That
is pinned by a test rather than left implicit.

docs/PLAY_SOUND_ARCHITECTURE.md gains the matching entry and corrects
two older passages that would otherwise describe the stop path wrongly.
The repository's ruff (0.14.14 in CI) wraps the lambda body rather than
the assignment. Both the pinned and the local formatter accept this
shape, so it is not a version-specific workaround.
@jleinenbach

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7045173610

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# rejection arrives as REJECTED_AUTH, and clearing the
# auth-failure state here would erase the very signal an expired
# sign-in produces.
return StopSoundOutcome.FAILED

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve NOT_SENT as a suppressed stop

When readiness succeeds but the action token disappears or is invalid by dispatch time, api.async_stop_sound() returns NOT_SENT, yet this blanket non-accepted branch converts it to StopSoundOutcome.FAILED. The service consequently reports stop_sound_rejected, whose contract says the request reached the transport, even though no request left the machine; map NOT_SENT to SUPPRESSED and reserve FAILED for attempted dispatches. This also contradicts the newly added SoundDispatchOutcome and StopSoundOutcome documentation.

AGENTS.md reference: AGENTS.md:L627-L629

Useful? React with 👍 / 👎.

Comment thread custom_components/googlefindmy/api.py Outdated
Comment on lines +1733 to +1735
return _play_result_after_failure(
err, request_uuid, SoundDispatchOutcome.REJECTED_AUTH
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish non-auth 4xx responses from credential rejection

For responses such as HTTP 400 or 404, async_nova_request() raises NovaAuthError (nova_request.py:1750-1755), so this handler now returns REJECTED_AUTH and logs an authentication failure even though SoundDispatchOutcome.REJECTED_AUTH is documented as credentials/401/403 only; the stop handler repeats the same mapping. Inspect err.status here and classify non-401/403 client rejections separately so API consumers and logs do not tell users to diagnose credentials for an invalid request or missing device.

AGENTS.md reference: AGENTS.md:L627-L629

Useful? React with 👍 / 👎.

`NovaAuthError` is raised for every non-retryable 4xx, not only for
credential rejections: the comment above the raise in nova_request.py names
"403 Forbidden, 404 Not Found" itself, and HTTP_RETRY_ELIGIBLE holds no 4xx
besides 408 and 429. A 401 that survives the refresh sequence is raised
separately with is_permanent=True, which leaves 403 as the only plain
credential rejection reaching these handlers.

Both sound handlers read the type alone, so a deleted device produced
SoundDispatchOutcome.REJECTED_AUTH and an "Authentication failed" ERROR --
telling a user with a perfectly good sign-in to check their credentials. The
neighbouring `except NovaHTTPError` already inspected `err.status`; the new
shared helper `_classify_nova_auth_error` gives the auth handler the same
rule: permanent first, then 401/403, everything else REJECTED_SERVER.

No consumer branches on REJECTED_AUTH, so the log line is the entire
user-visible effect of this split. It is asserted, level included: a mutation
that keeps the classification and restores the single blanket ERROR kills the
new guards while every enum assertion stays green.

Second finding, docs only: StopSoundOutcome.FAILED began with "Handed to the
transport" while listing the missing action token, which never reaches the
wire. Mapping NOT_SENT to SUPPRESSED instead -- as suggested -- would answer a
missing token with "try again in a moment", an unbounded retry loop; the
`stop_sound_rejected` message already names "a missing token" and points at
the log. The docs were corrected instead: the two failure states split by
REMEDY, not by distance travelled. That sentence lived in four places
(const.py twice, coordinator/locate.py's Returns contract, one test
docstring); all four now agree, and a stale "returns False" claim from the
bool era went with them.

Known gap, stated rather than silently left: the device-list handler, the
location handler, coordinator/polling.py and coordinator/locate.py still key
off the exception type, where a 404 turns into ConfigEntryAuthFailed or feeds
the reauth countdown. Narrowing those changes polling and setup behaviour and
needs its own regression test. Recorded in api.py's module header and in
custom_components/googlefindmy/AGENTS.md so a green suite is not mistaken for
tree-wide coverage.
@jleinenbach

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: c86e6b536a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@jleinenbach
jleinenbach marked this pull request as ready for review August 26, 2026 15:18
@jleinenbach
jleinenbach merged commit 5829518 into 1.7 Aug 26, 2026
24 checks passed
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