Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions devlog/_plan/260920_round2_followups/030_lane_r3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# R3 — the roster and login remainders

Status: implemented, awaiting review. Scope was #5292 and the two #5261 remainders.

## #5292 was already closed before this lane opened

The plan's table says `gui/src/pages/Logs.tsx` restates the recovery-kind union with nine of
thirteen members. That was true when the table was written and stopped being true two hours
earlier: `555f0cacdf` (#5300, 18:41) replaced the copy with the durable roster, and the plan
commit landed at 20:48 from a snapshot taken before it.

Current `dev` already has all of it. `Logs.tsx` imports `AttemptRecoveryKind` from
`src/usage/telemetry-contract.ts` and its label map closes with
`satisfies Record<AttemptRecoveryKind, string>`, so a fourteenth kind is a typecheck failure
there rather than an "Unknown recovery reason". All ten catalogs carry all thirteen labels plus
the fallback, and `tests/usage/request-outcome-agreement.test.ts` holds both: the label map has
to cover every member of `ATTEMPT_RECOVERY_KIND_ROSTER`, and every key it names has to exist in
every catalog. Verified by reading the tree, not by rerunning the suite.

Nothing was changed for it. The row is stale, not open.

## #5261, remainder one: the two CLI logins that discarded the launch

`src/oauth/login-cli.ts` called `void openUrl(...)` in both `handleOAuthLogin` and
`handleKeyLogin`. Each printed a URL, said it was opening a browser, and asked a question that
assumes it opened — indistinguishable from a login that is working.

The part that made this more than a missing `console.warn`: `OAuthController.onAuth` returns
`void` and every one of the thirteen provider call sites invokes it as `ctrl.onAuth?.(...)` and
moves on. The launcher's answer therefore arrives after the flow has continued, and on a
callback-server provider `#waitForCallback` has already called `onManualCodeInput` by then. A
warning written at that moment lands on the line the user is typing on.

Making `onAuth` awaitable would mean changing the controller contract and all thirteen call
sites, which is a much larger change than the defect deserves. Instead the launch reports itself
when it settles, and the two things that could collide with it wait on that report: the
manual-code prompt awaits it before asking, and the key login awaits it before it constructs a
reader at all. A polling provider that never prompts is still told before the login claims to
have worked.

`BROWSER_LAUNCH_FAILED_HINT` in `src/cli/account-auth.ts` kept its ChatGPT-specific second line
and now derives its first from `BROWSER_LAUNCH_FAILED_NOTICE`, so the sentence has one home
across all three logins.

The handlers took an optional deps object. The contract worth holding is an order, and an order
is only observable from something that records both events; spawning a launcher and attaching to
stdin to find that out would test the operating system. Production passes none of them.

## #5261, remainder two: the roster that kept last-good rows silently

`useCodexAccountPool` kept its rows after a failed read and also kept reporting `ready`. Keeping
the rows is right — blanking a populated pool because one 30s poll missed is its own defect — but
the surface then could not tell a list the server had just confirmed from one that predated a
failure. The reported shape: add an account, the read that would bring it over fails, and the
older accounts are on screen with the new one absent.

`refreshFailed` sits beside `loadState` rather than inside it, for the same reason `refreshing`
already does. `loadState` answers what the surface can draw and a warm failure does not change
that answer; folding it in would mean either flashing the cold skeleton over good data or saying
nothing. A cold failure still replaces the surface with the error it already had, and the banner
only renders when rows survived, so an empty cold failure is never annotated instead of explained.

## Verification

Static review and hosted CI at the exact head. The lane ran no local suite, no individual test,
no typecheck, no build, no install, no `ocx`, and changed no credential or configuration —
recorded as NOT RUN.

Checked by reading rather than running, because the ratchets are what a merge breaks:

- No file this lane touches appears in `tests/fixtures/file-size-baseline.json`. The ten i18n
catalogs are in its `exempt` list.
- `tests/oauth/oauth-login-cli-browser-launch.test.ts` is registered in both
`scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. The gui
suite has no layout guard.
- The one new i18n key is in all ten catalogs, which `gui/tests/locale-parity.test.ts` and
`gui/tests/claude-desktop-locale.test.ts` both require.
- `CodexAccountLoadState` gained no member. `CodexAccountPoolController` gained one, and the
source-oracle roster in `gui/tests/codex-account-pool-controller.test.ts` names it.
- `CodexAccountPoolLoadStates` stopped restating the load-state union and derives it.

## The GUI screenshot gate

`enforce-target` requires a screenshot for a PR that touches `gui`. Producing one needs
`bun run build:gui` and a running proxy, both of which this lane is forbidden to do, so the pull
request says so and offers what can be checked instead: the rendered markup is asserted against a
mounted DOM in `gui/tests/codex-account-pool-stale-refresh.test.tsx` — the banner appears with
surviving rows, carries the catalog string, does not appear on a successful refresh, and does not
replace the cold error — and the new class reuses the existing `.pwi-auth-state` block with the
`--amber` pair already used elsewhere in the theme.
3 changes: 2 additions & 1 deletion gui/src/components/CodexAccountPool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
// but stays inert (no load, no polling) whenever a shared controller was injected.
const ownController = useCodexAccountPool(apiBase, !injectedController);
const controller = injectedController ?? ownController;
const { accounts, activeId, loadState, switchingId, pauseUpdatingId, priorityUpdatingId, pausingExhausted, activePinnedId, load } = controller;
const { accounts, activeId, loadState, refreshFailed, switchingId, pauseUpdatingId, priorityUpdatingId, pausingExhausted, activePinnedId, load } = controller;
// #3898: the native-main device reauth drives the dedicated namespace; a
// completed flow refreshes the account list so the card leaves reauth state.
const mainReauth = useMainDeviceReauth(apiBase, () => { void load(); });
Expand Down Expand Up @@ -476,6 +476,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
<CodexAccountPoolLoadStates
t={t}
loadState={loadState}
refreshFailed={refreshFailed}
accountsCount={accounts.length}
onRetry={() => { void load(); }}
/>
Expand Down
17 changes: 15 additions & 2 deletions gui/src/components/codex-account-pool-main-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { IconLock, IconPause, IconPlay, IconPlus, IconRefresh, IconTicket } from
import AccountPriorityControl, { AccountPriorityBadge } from "./AccountPriorityControl";
import QuotaBars from "./QuotaBars";
import { CodexPauseToggleLabel, CodexTicketBadge } from "./codex-account-pool-helpers";
import type { CodexAccountEntry } from "./codex-account-pool-types";
import type { CodexAccountEntry, CodexAccountLoadState } from "./codex-account-pool-types";
import type { CodexAccountModeState } from "../codex-multi-state";
import type { TFn } from "../i18n/shared";
import type { MainDeviceReauthState } from "./use-main-device-reauth";
Expand Down Expand Up @@ -359,11 +359,13 @@ export function CodexAccountPoolActions(props: {
export function CodexAccountPoolLoadStates({
t,
loadState,
refreshFailed,
accountsCount,
onRetry,
}: {
t: TFn;
loadState: "loading" | "ready" | "error";
loadState: CodexAccountLoadState;
refreshFailed: boolean;
accountsCount: number;
onRetry: () => void;
}): ReactNode {
Expand Down Expand Up @@ -419,5 +421,16 @@ export function CodexAccountPoolLoadStates({
</div>
);
}
// Rows survived a failed refresh, so they are still worth showing — but they are the ones from
// before it, and an account added since is simply not among them. A status rather than an alert:
// nothing on screen is wrong, it is just older than it looks.
if (refreshFailed && accountsCount > 0) {
return (
<div className="pwi-auth-state pwi-auth-state--stale" role="status">
<span>{t("codexAuth.accountsRefreshFailed")}</span>
<button type="button" className="btn btn-ghost btn-sm" onClick={onRetry}>{t("pws.retryAccounts")}</button>
</div>
);
}
return null;
}
2 changes: 1 addition & 1 deletion gui/src/components/codex-account-pool-types.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export type { CodexAccountEntry } from "../hooks/useCodexAccountPool";
export type { CodexAccountEntry, CodexAccountLoadState } from "../hooks/useCodexAccountPool";
23 changes: 21 additions & 2 deletions gui/src/hooks/useCodexAccountPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ export interface CodexAccountPoolController {
* `ready` during a refresh so rows survive; this is what makes that wait visible.
*/
refreshing: boolean;
/**
* The most recent account read failed. The rows it could not replace are still on screen, so
* this is the only thing that tells a surface they are no longer known to be current.
*/
refreshFailed: boolean;
/** True until the first load attempt settles, whether it succeeds or fails. */
initialLoading: boolean;
switchingId: string | null;
Expand Down Expand Up @@ -160,6 +165,13 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
);
const [activeId, setActiveId] = useState<string | null>(() => seed?.activeId ?? null);
const [loadState, setLoadState] = useState<CodexAccountLoadState>(() => (seed != null ? "ready" : "loading"));
// Deliberately beside `loadState` rather than inside it. `loadState` answers what the surface
// can draw, and a warm refresh failure keeps the rows drawable — folding the failure in would
// mean either flashing the cold skeleton over good data or, as before, saying nothing at all.
// Saying nothing is the defect: the rows on screen are the ones from before the refresh, so an
// account the user has just added is simply absent while the older ones look current (#5261).
// `refreshing` already set the precedent that a fact about the read lives next to loadState.
const [refreshFailed, setRefreshFailed] = useState(false);
const [switchingId, setSwitchingId] = useState<string | null>(null);
const [pauseUpdatingId, setPauseUpdatingId] = useState<string | null>(null);
const [priorityUpdatingId, setPriorityUpdatingId] = useState<string | null>(null);
Expand Down Expand Up @@ -277,6 +289,10 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
hasLoadedRef.current = true;
// Progressive: paint account/quota boxes as soon as /accounts returns.
setLoadState("ready");
// Cleared here rather than at the settle below, because the rows it qualifies are
// painted here. Waiting for /active to finish would leave the just-replaced rows
// labelled as pre-refresh ones for as long as that read's budget allows.
setRefreshFailed(false);
}
return true;
} catch {
Expand Down Expand Up @@ -329,9 +345,11 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
});
return activeOk;
}
// Cold failure only: after a successful load (including empty), keep rows and stay ready
// so a soft poll miss does not flash the skeleton / wipe the pool.
// A cold failure has nothing to show, so it replaces the surface. A warm one keeps its rows
// — flashing the skeleton on a soft poll miss is its own defect — and says so instead of
// continuing to present them as current.
if (!hasLoadedRef.current) setLoadState("error");
setRefreshFailed(true);
Comment on lines +348 to +352

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 Surface account failures before waiting for /active

When /accounts fails quickly but the concurrent /active request hangs, this state update is not reached until Promise.all([accountsTask, activeTask]) settles. That can leave the old roster looking current for the shared 20-second timeout—or substantially longer during validatePending—even though the account failure is already known, preserving the misleading post-add state this change is intended to fix. Set the generation-guarded failure state in accountsTask's failure path instead of waiting for the unrelated active-account read.

Useful? React with 👍 / 👎.

return false;
} finally {
bounded.clear();
Expand Down Expand Up @@ -623,6 +641,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou
activeId,
loadState,
refreshing: inflightCount > 0,
refreshFailed,
initialLoading: !firstAttemptSettled,
switchingId,
pauseUpdatingId,
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1670,6 +1670,7 @@ export const de: Record<TKey, string> = {
"accountPool.priorityUpdateFailed": "Die Auswahlreihenfolge für {email} konnte nicht gespeichert werden. Der zuletzt bestätigte Wert wird angezeigt.",
"codexAuth.switched": "{email} ist für die nächste Anfrage ausgewählt",
"codexAuth.loadFailed": "Die Codex-Kontoeinstellungen konnten nicht geladen werden.",
"codexAuth.accountsRefreshFailed": "Die letzte Kontoaktualisierung ist fehlgeschlagen. Unten stehen die zuletzt bestätigten Konten.",
"codexAuth.switchFailed": "Das Konto konnte nicht gewechselt werden. Die vorherige Auswahl bleibt erhalten.",
"codexAuth.removeConfirm": "{id} entfernen?",
"codexAuth.removeFailed": "Das Konto konnte nicht entfernt werden. Es wurde nichts geändert.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2264,6 +2264,7 @@ export const en = {

"codexAuth.switched": "{email} is selected for the next request",
"codexAuth.loadFailed": "Codex account settings could not be loaded.",
"codexAuth.accountsRefreshFailed": "The latest account refresh failed. The accounts below are the last ones confirmed.",
"codexAuth.switchFailed": "The account could not be switched. Your previous selection is unchanged.",
"codexAuth.removeConfirm": "Remove {id}?",
"codexAuth.removeFailed": "The account could not be removed. Nothing was changed.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2186,6 +2186,7 @@ export const fr: Record<TKey, string> = {
"accountPool.priorityUpdateFailed": "Impossible d’enregistrer l’ordre de sélection de {email}. La dernière valeur confirmée est affichée.",
"codexAuth.switched": "{email} est sélectionné pour la prochaine requête",
"codexAuth.loadFailed": "Impossible de charger les paramètres des comptes Codex.",
"codexAuth.accountsRefreshFailed": "La dernière actualisation des comptes a échoué. Les comptes ci-dessous sont les derniers confirmés.",
"codexAuth.switchFailed": "Impossible de changer de compte. Votre sélection précédente reste inchangée.",
"codexAuth.removeConfirm": "Supprimer {id} ?",
"codexAuth.removeFailed": "Impossible de supprimer le compte. Aucune modification apportée.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2114,6 +2114,7 @@ export const ja: Record<TKey, string> = {
"accountPool.priorityUpdateFailed": "{email} の選択順序を保存できませんでした。最後に確認された値を表示しています。",
"codexAuth.switched": "次のリクエストでは {email} を使用します",
"codexAuth.loadFailed": "Codex アカウント設定を読み込めませんでした。",
"codexAuth.accountsRefreshFailed": "最新のアカウント更新に失敗しました。以下は最後に確認されたアカウントです。",
"codexAuth.switchFailed": "アカウントを切り替えられませんでした。以前の選択はそのままです。",
"codexAuth.removeConfirm": "{id} を削除しますか?",
"codexAuth.removeFailed": "アカウントを削除できませんでした。何も変更されていません。",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1706,6 +1706,7 @@ export const ko: Record<TKey, string> = {
"accountPool.priorityUpdateFailed": "{email}의 선택 순서를 저장하지 못했습니다. 마지막으로 확인된 값을 표시합니다.",
"codexAuth.switched": "다음 요청에 {email}을(를) 사용합니다",
"codexAuth.loadFailed": "Codex 계정 설정을 불러오지 못했습니다.",
"codexAuth.accountsRefreshFailed": "최신 계정 새로고침에 실패했습니다. 아래 목록은 마지막으로 확인된 계정입니다.",
"codexAuth.switchFailed": "계정을 전환하지 못했습니다. 이전 선택은 그대로 유지됩니다.",
"codexAuth.removeConfirm": "{id}을(를) 삭제하시겠습니까?",
"codexAuth.removeFailed": "계정을 제거하지 못했습니다. 변경된 내용은 없습니다.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2185,6 +2185,7 @@ export const ru: Record<TKey, string> = {
"accountPool.priorityUpdateFailed": "Не удалось сохранить порядок выбора для {email}. Показано последнее подтверждённое значение.",
"codexAuth.switched": "{email} выбран для следующего запроса",
"codexAuth.loadFailed": "Не удалось загрузить настройки аккаунтов Codex.",
"codexAuth.accountsRefreshFailed": "Последнее обновление аккаунтов не удалось. Ниже показаны последние подтверждённые аккаунты.",
"codexAuth.switchFailed": "Не удалось переключить аккаунт. Ваш предыдущий выбор не изменён.",
"codexAuth.removeConfirm": "Удалить {id}?",
"codexAuth.removeFailed": "Не удалось удалить аккаунт. Ничего не изменено.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2205,6 +2205,7 @@ export const tr: Record<TKey, string> = {

"codexAuth.switched": "Sonraki istek için {email} seçildi",
"codexAuth.loadFailed": "Codex hesap ayarları yüklenemedi.",
"codexAuth.accountsRefreshFailed": "Son hesap yenilemesi başarısız oldu. Aşağıda son doğrulanan hesaplar gösteriliyor.",
"codexAuth.switchFailed": "Hesap değiştirilemedi.",
"codexAuth.removeConfirm": "{id} kaldırılsın mı?",
"codexAuth.removeFailed": "Hesap kaldırılamadı.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/vi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2202,6 +2202,7 @@ export const vi: Record<TKey, string> = {
"accountPool.priorityUpdateFailed": "Không thể lưu thứ tự lựa chọn cho {email}. Giá trị được xác nhận gần nhất đang được hiển thị.",
"codexAuth.switched": "Tài khoản được chọn cho request tiếp theo là {email}",
"codexAuth.loadFailed": "Không thể tải cài đặt tài khoản Codex.",
"codexAuth.accountsRefreshFailed": "Lần làm mới tài khoản gần nhất thất bại. Dưới đây là các tài khoản được xác nhận gần nhất.",
"codexAuth.switchExceedsThresholdWarning": "Mức sử dụng của tài khoản này đã đạt hoặc vượt ngưỡng chuyển đổi ({threshold}%). Lựa chọn đã ghim sẽ được giải phóng nếu không còn hạn ngạch khả dụng.",
"codexAuth.switchFailed": "Không thể chuyển đổi tài khoản. Tùy chọn trước đó của bạn vẫn được giữ nguyên.",
"codexAuth.removeConfirm": "Gỡ bỏ {id}?",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1669,6 +1669,7 @@ export const zhTW: Record<TKey, string> = {
"accountPool.quotaWindowInert": "只有配額策略,或門檻大於 0 的填滿優先策略,才會依用量計分;在目前的輪換策略下,這項設定不會有任何作用。",
"codexAuth.switched": "下一次請求將使用 {email}",
"codexAuth.loadFailed": "無法載入 Codex 帳號設定。",
"codexAuth.accountsRefreshFailed": "最近一次帳號重新整理失敗。以下是最後一次確認的帳號。",
"codexAuth.switchFailed": "無法切換帳號。之前的選擇保持不變。",
"codexAuth.removeConfirm": "刪除 {id}?",
"codexAuth.removeFailed": "無法移除帳號。未進行任何更改。",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1687,6 +1687,7 @@ export const zh: Record<TKey, string> = {
"accountPool.priorityUpdateFailed": "无法保存 {email} 的选择顺序。当前显示最后一次确认的值。",
"codexAuth.switched": "下一次请求将使用 {email}",
"codexAuth.loadFailed": "无法加载 Codex 账号设置。",
"codexAuth.accountsRefreshFailed": "最近一次账号刷新失败。以下是最后一次确认的账号。",
"codexAuth.switchFailed": "无法切换账户。之前的选择保持不变。",
"codexAuth.removeConfirm": "删除 {id}?",
"codexAuth.removeFailed": "无法移除账户。未进行任何更改。",
Expand Down
1 change: 1 addition & 0 deletions gui/src/styles/provider-workspace-settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
background: var(--raised); border-radius: var(--radius-xs);
}
.pwi-auth-state--error { color: var(--red); background: var(--red-soft); justify-content: space-between; }
.pwi-auth-state--stale { color: var(--amber); background: var(--amber-soft); justify-content: space-between; }
.pwi-auth-state--empty { justify-content: center; }

.pwi-auth-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
Expand Down
Loading
Loading