Observed 2026-08-28 while closing MOTIR-3743. Yue added motir-marketing to the existing Motir GitHub App installation and was shown a red banner:
"We couldn't finish setting up the GitHub App. Please try installing again."
Nothing had gone wrong. The repository was connected, and two independent calls proved it minutes later: a targetRepo pin of motir-marketing was accepted (it had returned UNKNOWN_TARGET_REPO), and link_pull_request returned created: true (it had returned GITHUB_REPO_NOT_FOUND). The banner is the one signal the person gets, and it told them the opposite of what happened.
app/api/github/setup/route.ts:
if (setupAction && setupAction !== 'install' && setupAction !== 'update') {
return settingsRedirect('installed');
}
if (!installationId || !state) return settingsRedirect('install_error');
state is a signed, self-contained token minted only by Motir's own install link — encodeInstallState is called in exactly one place, app/(authed)/settings/workspace/github/page.tsx. So the state exists only when the round trip STARTED in Motir.
Editing an installation's repository selection does not start in Motir. It starts on github.com, in the app's installation settings, and GitHub redirects to the Setup URL with installation_id and setup_action=update and no state at all — because there was no Motir link to carry one. The !state arm fires and the person sees install_error.
And the binding it could not re-establish was one it did not need to establish. The installation → workspace binding already existed (motir-core, motir-ai, motir-gateway, motir-meta and the starter were all connected through it). What changed was the installation's REPOSITORY SET, which is maintained by the installation_repositories webhook — which is why the repository really did connect while the redirect was reporting failure.
A second, narrower path to the same banner: TTL_SECONDS = 600 in lib/github/installState.ts. A person who does start from Motir's link but spends more than ten minutes on GitHub's repository-picker gets an expired state, !decoded, and the identical message.
It fires on the normal way to add a repository to an existing installation, so every future repository — and every repository-set edit — produces a false alarm. The instruction it gives is actively wrong twice over: there is nothing to finish, and "try installing again" invites re-running an install that already succeeded. The most likely reaction is to retry, see the same banner, and conclude Motir is broken; the second most likely is to believe the repository is unconnected and go looking for a problem that does not exist. Both are what happened here.
setup_action=update arriving with no state is not an error. The installation binding already exists; the correct outcome is the neutral/installed banner, or a distinct message that says what actually happened (the repository selection changed) rather than that setup failed.install_error returns are distinguishable in the redirect, so the banner can say which one occurred. Today a missing state, a tampered state, an expired state, a cross-user state, a non-member actor and a provider failure are one string; at minimum an EXPIRED state must read differently from a missing one, because the remedy differs (start again from Motir vs. nothing is wrong).setup_action=update with no state and asserts the banner is NOT install_error. The bug is that a successful operation reports failure, so the regression guard has to be on the success case.app/api/github/setup/route.ts — the four install_error returns and the install/update arm.lib/github/installState.ts — TTL_SECONDS = 600, and the header explaining why the state is self-verifying (the install starts from a bare GitHub URL, so no cookie can be set).app/(authed)/settings/workspace/github/page.tsx — the only caller of encodeInstallState, and the banner's severity map (install_error: 'danger').messages/en.json → banner.install_error.