diff --git a/desktop/src-tauri/src/native_tray.rs b/desktop/src-tauri/src/native_tray.rs index 58731cb04d3..583c3378314 100644 --- a/desktop/src-tauri/src/native_tray.rs +++ b/desktop/src-tauri/src/native_tray.rs @@ -334,11 +334,21 @@ fn publish(app: &AppHandle, generation: u64, binding: Option, sn *state .cache .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = (binding, snapshot); + .unwrap_or_else(std::sync::PoisonError::into_inner) = (binding, cached(snapshot)); } }); } +/// The cached copy every later refresh starts from. `switchFailed` answers one switch, so it is +/// delivered once and never cached: carried forward, it would settle the next switch's spinner on +/// that switch's first loading publish. +fn cached(mut snapshot: Value) -> Value { + if let Some(fields) = snapshot.as_object_mut() { + fields.remove("switchFailed"); + } + snapshot +} + fn display_payload(snapshot: Value) -> Option<(Value, Vec)> { let bytes = serde_json::to_vec(&snapshot).ok()?; if bytes.len() <= 8 * 1024 * 1024 { @@ -406,6 +416,11 @@ mod tests { } #[test] fn refresh_failure_preserves_age_and_clears_busy_state() { + let reported = json!({"refreshing":false,"errors":["refused"],"switchFailed":true}); + let kept = cached(reported); + assert!(kept.get("switchFailed").is_none()); + assert_eq!(kept["errors"], json!(["refused"])); + let before = json!({"updatedAt":12,"refreshing":true,"today":{"totalTokens":30}}); let after = failed(before, "Unavailable"); assert_eq!(after["updatedAt"], 12); diff --git a/devlog/_plan/260927_directive_marker_bridge/030_done.md b/devlog/_plan/260927_directive_marker_bridge/030_done.md new file mode 100644 index 00000000000..103964137d8 --- /dev/null +++ b/devlog/_plan/260927_directive_marker_bridge/030_done.md @@ -0,0 +1,35 @@ +# 030 — done: Codex App visualization references for routed models + +## Outcome + +Any routed model can now show a Codex App inline visualization. #6040 (`a1285fc648`) stopped the +citation filter from deleting non-citation directives; #6045 (`dc784d3e6f`) rewrites the private-use +`visualize` reference into the app's own `::codex-inline-vis{…}` directive in the text routed models +read, so a model whose provider drops private-use characters still reads and writes a form the app renders. + +## Evidence + +- App bundle 26.924.22138: `f2`, `Err` and `i3e` (see 001) render the ASCII directive directly. +- Local app rebuilt from `dc784d3e6f` with a forced standalone sidecar; the installed `ocx` reports + 2.68.0 and contains `codex-inline-vis`; `codesign --verify --deep --strict` passes. +- Live, through the installed proxy: `anthropic/claude-opus-5-5` and `cursor/claude-opus-5-5` answered a + private-use reference with `::codex-inline-vis{path="/tmp/demo-chart.html"}`; `gpt-6-luna` returned the + private-use form unchanged; `xai/grok-4.7` received and quoted the private-use form. +- Render: a Claude-routed final answer carrying `::codex-inline-vis{…}` rendered as an interactive widget in + Codex App; the widget reported `route: Claude, version: #6045, outcome: renders` back to the thread. +- Reviews: architect Fermat, auditor Lovelace, reviewer Archimedes (132,715 `f2` parity cases), and two + release regression lanes over #6040 and #6045 found no regression. + +## What did not go to plan + +- `desktop/scripts/prepare-sidecar.ts` reuses `dist/standalone/*/ocx` when it exists, so the first rebuilt + app shipped a stale 2.61.0 proxy. The standalone build has to be forced before `prepare-sidecar`. +- A commentary message is recorded as a reasoning summary on this route, which the app does not render + as markdown directives; the render check needed a final answer. +- #6045 merged by admin at the owner's instruction before its PR CI finished; the post-merge lane=all run + on `dc784d3e6f` is the CI evidence for the merged tree. + +## Follow-ups + +- Make `prepare-sidecar` rebuild when the source is newer than the cached standalone binary. +- Replies already stored in the bare `visualize{…}` form are not repaired. diff --git a/devlog/_plan/260927_release_2680/000_plan.md b/devlog/_plan/260927_release_2680/000_plan.md new file mode 100644 index 00000000000..8cfde2f80a8 --- /dev/null +++ b/devlog/_plan/260927_release_2680/000_plan.md @@ -0,0 +1,32 @@ +# 260927 release 2.68.0 — plan + +## Reader summary + +The owner asked (2026-09-27) for a main..dev regression review with astra reviewers, for the +Windows/Linux tray to gain the menu bar patches the macOS panel received, and for a full 2.68.0 +release. `origin/main` is v2.67.0 (`4bc92294aa`); `origin/dev` (`dc784d3e6f`) carries 86 commits +beyond it. Procedure follows [the 2.67.0 round](../../_fin/260926_release_2670/020_wp3_release.md); +only values differ. The owner asked for CI to be judged heuristically: a failure is a blocker only +when it reproduces or is tied to a change in the range. + +## Work-phase map + +| wp | Doc | Change | +|---|---|---| +| wp4 | [010](010_wp4_blockers_and_tray.md) | release blockers from the review, Windows tray parity | +| wp5 | [020](020_wp5_release.md) | candidate CI, pre-move to 2.69.0, promotion, publish, verify | + +## Review lanes (astra, read-only) + +| Lane | Range | Verdict | +|---|---|---| +| #6040 citation filter | `a1285fc648` | OK (700,168 comparisons) | +| #6045 visualization references | `dc784d3e6f` | OK | +| dev sanity + CI classification | whole range | OK; Devin test mock leak (test-only) | +| merge trains 1-5 | #5901..#5909 | OK | +| desktop, batches 6-8 | #5910..#5957 | BLOCK: native tray `switchFailed` cached | +| Kiro series | #5967..#6016 | BLOCK: first discovery failure never backs off | +| batches 9-10 | #5984..#6031 | BLOCK: Home-initiated Remote Link answers 503 | + +Owner disposition for the Remote Link finding (2026-09-27): keep 2.67.0 behaviour for Home-initiated +links only; Child-initiated links keep the new ownership proof. diff --git a/devlog/_plan/260927_release_2680/010_wp4_blockers_and_tray.md b/devlog/_plan/260927_release_2680/010_wp4_blockers_and_tray.md new file mode 100644 index 00000000000..cae311c70e6 --- /dev/null +++ b/devlog/_plan/260927_release_2680/010_wp4_blockers_and_tray.md @@ -0,0 +1,47 @@ +# 010 — wp4: release blockers and Windows tray parity + +## Fixes (one PR to dev) + +| Finding | Files | Change | Proof | +|---|---|---|---| +| Kiro discovery failure without a last good list retried on every request | `src/providers/kiro-model-catalog.ts` | `failedUntil` map carries the 60 s retry per account identity; cleared on success and by `clearKiroAccountModels` | new case in `tests/providers/kiro/kiro-model-catalog.test.ts` fails before (2 calls), passes after (1) | +| Native tray `switchFailed` cached and settling later switches | `desktop/src-tauri/src/native_tray.rs` | `cached()` drops the one-shot flag from the snapshot every refresh starts from | `cargo test --lib native_tray` | +| Home-initiated Child relays answer 503 | `src/client/link-relay.ts`, `src/client/runtime.ts` | explicit `HOME_INITIATED_LINK_TUNNEL` gate for a link-mode runtime without a sidecar; a relay with no gate still refuses | new case in `tests/clients/client-link-relay.test.ts`; the lane's repro passes | +| Devin preflight test leaks its adapter mock | `tests/responses/responses-grok-devin-preflight.test.ts` | restore the module in `afterAll` | 3-file run 82 pass (was 55/27) | + +## Windows/Linux tray parity (web tray `gui/src/pages/Tray.tsx`) + +| macOS patch | Web tray before | Change | +|---|---|---| +| #5920 windows that report data | present | none | +| #5922 provider marks, severity bars | missing | `ProviderIcon` in headings; `quotaSeverity` classes on bars (70/90) | +| #5931 switch the active account | missing | `switchState`/`exhausted` in `parseAccounts`, `accountSwitchRequest` routes, "Use this account" on hover/focus, pending and failure states | +| #5921 widget reload | not applicable (macOS widget) | none | + +The popup sends the switch with the dashboard session (`window.fetch` is session-wrapped by +`gui/src/api.ts`), not the desktop capability the native panel uses. + +## Verification + +`bun run typecheck`, GUI `tsc` and lint, `bun run structure:check`, `bun run privacy:scan`, the focused +test files above, `gui/tests/tray-data.test.ts`, and a Playwright capture of the web tray against the +running proxy. Full suite: PR CI. + +## Audit round 1 (Carver, astra) — FAIL, dispositions + +1. GUI build: `providerSources` `flatMap` inferred only `'codex'` — folded (`flatMap`; `tsc -p tsconfig.app.json` exit 0). +2. A Child-initiated link whose sidecar was deleted took the Home-initiated gate — folded. A join now + writes `link/child-initiated.json` with the link id; the runtime uses the Home gate only when neither + the sidecar nor a matching marker exists, and an unreadable marker counts as present. The auditor's + repro now answers 503 with no send for deleted and corrupt sidecars and for hub transport. + Residual: a 2.67.0 join made before this marker existed, with its sidecar later deleted, is treated as + Home-initiated, which is the 2.67.0 behaviour for every link. +3. Switch settled before the reload — folded: the row stays pending until the reload the switch started completes. +4. Unbounded PUT — folded: `createBoundedFetch(20 s)`; a timeout reports the switch failure. +Note (not folded): the web tray does not print `blockedReason`; a blocked account simply offers no "Use". +5. Round 2: pre-marker joins — partly folded. The runtime records the marker at start whenever the + sidecar is intact (`recordChildInitiatedLink`), so a pre-marker join that starts once on 2.68.0 is + protected from then on. Rebutted for the remaining case, a pre-marker join whose sidecar was deleted + before its first 2.68.0 start: that state is indistinguishable from a 2.67.0 Home-initiated link, and + failing it closed would break every existing Home-initiated link, which the owner chose to keep working. + It keeps exactly the 2.67.0 behaviour, the owner-accepted baseline. diff --git a/devlog/_plan/260927_release_2680/020_wp5_release.md b/devlog/_plan/260927_release_2680/020_wp5_release.md new file mode 100644 index 00000000000..2a79bda6dd5 --- /dev/null +++ b/devlog/_plan/260927_release_2680/020_wp5_release.md @@ -0,0 +1,11 @@ +# 020 — wp5: release 2.68.0 + +Values for the 2.67.0 procedure: `CAND` = `origin/dev` after wp4 merges; `PV=2.68.0-preview.20260927`; +pre-move `dev-version-bump.yml --ref main -f intended-version=2.68.0 -f mode=pre-move` (dev → 2.69.0); +promotion branches `codex/260927-release-preview-2.68.0` and `codex/260927-release-main-2.68.0` built +with `git merge -s ours` and `scripts/release-version-sources.ts`; merge commits (never squash); push-event +CI and Service lifecycle at both promotion SHAs; `release.yml` preview first, then stable; verify npm +dist-tags, both GitHub releases' assets, and `latest.json` signatures; fast-forward local branches. + +Heuristic CI rule (owner): a failing job blocks only when it reproduces on rerun or its log points at a +change in main..dev. Runner-stall signatures get one job rerun. diff --git a/gui/src/pages/Tray.tsx b/gui/src/pages/Tray.tsx index af4c2bcbfa9..f3cc5b34ea3 100644 --- a/gui/src/pages/Tray.tsx +++ b/gui/src/pages/Tray.tsx @@ -1,13 +1,19 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { createBoundedFetch } from '../bounded-fetch'; import { useI18n } from '../i18n/shared'; import { formatTokens } from '../format-tokens'; import { formatProviderDisplayName } from '../provider-icons'; +import { ProviderIcon } from '../components/provider-workspace/ProviderRail'; +import { quotaSeverity } from '../quota-summary'; import { UsageCompanionChart } from './usage-companion-chart'; import { companionTimelineQuery, companionTimelineProjection, type CompanionSettings, type CompanionSettingsResponse, type UsageTimeline } from './usage-companion-utils'; -import { fetchTrayJson, parseTrayUsage, filterUsage, measuredTotals, finite, parseAccounts, providerSources, quotaWindows, relativeReset, type TrayProvider, type TrayTotals, type TrayUsage } from './tray-data'; +import { accountSwitchRequest, fetchTrayJson, parseTrayUsage, filterUsage, measuredTotals, finite, parseAccounts, providerSources, quotaWindows, relativeReset, type TrayProvider, type TraySwitchKind, type TrayTotals, type TrayUsage } from './tray-data'; declare global { interface Window { __OPENCODEX_TRAY_VISIBLE__?: boolean } } +/** A switch that has not answered by then is reported as failed rather than left spinning. */ +const SWITCH_TIMEOUT_MS = 20_000; + const incomplete = (data: TrayUsage | null | undefined) => data?.usageIncomplete || data?.historyTruncated || data?.entriesTruncated; export default function Tray() { @@ -23,7 +29,30 @@ export default function Tray() { const [updatedAt, setUpdatedAt] = useState(null); const [refreshing, setRefreshing] = useState(false); const [revision, setRevision] = useState(0); + const [switching, setSwitching] = useState(null); + const [switchError, setSwitchError] = useState(null); const retry = () => setRevision(value => value + 1); + // Set after a successful switch: the pending row stays busy until the reload it started lands. + const awaitingRefresh = useRef(false); + // The same route and body the native panel sends; the dashboard session supplies the + // credentials. The switch settles only when the reload shows the runtime's own selection. + const switchAccount = async (provider: string, kind: TraySwitchKind, accountId: string) => { + setSwitching(`${provider}:${accountId}`); + setSwitchError(null); + const bounded = createBoundedFetch(SWITCH_TIMEOUT_MS); + try { + const request = accountSwitchRequest(provider, kind, accountId); + const response = await fetch(request.path, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request.body), signal: bounded.signal }); + if (!response.ok) throw new Error(String(response.status)); + awaitingRefresh.current = true; + retry(); + } catch { + setSwitching(null); + setSwitchError(t(kind === 'codex' ? 'codexAuth.switchFailed' : 'prov.accountSwitchFail')); + } finally { + bounded.clear(); + } + }; // Every post-await state write checks both effect disposal and the request's AbortSignal. // react-doctor-disable-next-line react-doctor/no-set-state-after-await-in-effect @@ -49,7 +78,7 @@ export default function Tray() { try { const sources = providerSources(await json('/api/config')); const rows = await Promise.all(sources.map(async source => { - if (!source.path) return { name: source.name, accounts: [] }; + if (!source.path) return { name: source.name, switchKind: source.switchKind, accounts: [] }; try { const payload = await json>(source.path); if (source.name === 'openai') { @@ -58,9 +87,9 @@ export default function Tray() { payload.activeCodexAccountId = selection.activeCodexAccountId ?? '__main__'; } catch { /* Missing selection is unknown, never inferred from quota. */ } } - return { name: source.name, accounts: parseAccounts(payload) }; + return { name: source.name, switchKind: source.switchKind, accounts: parseAccounts(payload) }; } - catch { return { name: source.name, accounts: [], unavailable: true }; } + catch { return { name: source.name, switchKind: source.switchKind, accounts: [], unavailable: true }; } })); if (active()) { setProviders(rows); setQuotaError(false); hadSuccess = true; } } catch { if (active()) { setProviders([]); setQuotaError(true); } } @@ -95,7 +124,11 @@ export default function Tray() { await Promise.allSettled([quotas, metrics]); } finally { busy = false; - if (active()) { setRefreshing(false); if (hadSuccess) setUpdatedAt(Date.now()); } + if (active()) { + setRefreshing(false); + if (hadSuccess) setUpdatedAt(Date.now()); + if (awaitingRefresh.current) { awaitingRefresh.current = false; setSwitching(null); } + } if (!disposed && current.signal.aborted && visible()) void load(); } }; @@ -152,11 +185,23 @@ export default function Tray() { } {(settings?.showAccounts ?? true) &&
{quotaError &&

{t('startup.tray.unavailable')}

} + {switchError &&

{switchError}

} {providers.filter(provider => !hiddenProviders.has(provider.name)).map(provider =>
-

{formatProviderDisplayName(provider.name, t)}

+

{formatProviderDisplayName(provider.name, t)}

{!provider.accounts.length &&
{t(provider.unavailable ? 'startup.tray.unavailable' : 'pws.dashboard.noQuota')}
} - {provider.accounts.map(account =>
-
{account.label}{account.plan}{account.active && ●}
+ {provider.accounts.map(account => { + const kind = provider.switchKind; + const pending = switching === `${provider.name}:${account.id}`; + return
+
+ {account.label}{account.exhausted && ⚠} + + {pending && {t('pws.accountSwitching')}} + {!pending && kind && account.switchState === 'available' && } + {account.plan} + {account.active && ✓} + +
{account.email && account.email !== account.label &&
{account.email}
} {!quotaWindows(account.quota).length &&
{t(account.unavailable ? 'startup.tray.unavailable' : 'pws.dashboard.noQuota')}
} {quotaWindows(account.quota).map(window => { @@ -165,11 +210,12 @@ export default function Tray() { const percent = finite(window.percent) ? Math.min(100, window.percent) : null; return
{label}{percent === null ? '—' : `${Math.round(percent)}%`} - - -
; - })} -
)} + + +
; + })} +
; + })} )}
}
{t('tray.updated', { time: updatedAt === null ? '—' : new Date(updatedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) })}
diff --git a/gui/src/pages/tray-data.ts b/gui/src/pages/tray-data.ts index ef58b46aec5..72fa5dc06c8 100644 --- a/gui/src/pages/tray-data.ts +++ b/gui/src/pages/tray-data.ts @@ -6,24 +6,54 @@ import { normalizeQuotaForPlan } from '../codex-quota-utils'; export type TrayTotals = Partial>; export type TrayModel = TrayTotals & { model: string; provider: string }; export interface TrayUsage { summary: TrayTotals; models: TrayModel[]; customWindow?: boolean; since?: number; until?: number; usageIncomplete?: boolean; historyTruncated?: boolean; entriesTruncated?: boolean } -export interface TrayAccount { unavailable?: boolean; id: string; label: string; quota: AccountQuota | null; plan?: string; active?: boolean; email?: string; status?: string; quotaFailure?: string } -export interface TrayProvider { name: string; accounts: TrayAccount[]; unavailable?: boolean } -export interface TrayProviderSource { name: string; path: string | null } +/** + * Whether this account can be made active from the tray. Mirrors the native panel + * (`desktop/src-tauri/src/native_tray_accounts.rs` `switch_state`): only what the switch route + * itself refuses is blocked, and an exhausted account stays switchable. + */ +export type TraySwitchState = 'active' | 'available' | 'blocked'; +export type TraySwitchKind = 'codex' | 'oauth' | 'apiKey'; +export interface TrayAccount { unavailable?: boolean; id: string; label: string; quota: AccountQuota | null; plan?: string; active?: boolean; email?: string; status?: string; quotaFailure?: string; switchState?: TraySwitchState; blockedReason?: 'mainHardLock' | 'paused' | 'validationPending'; exhausted?: boolean } +export interface TrayProvider { name: string; accounts: TrayAccount[]; unavailable?: boolean; switchKind?: TraySwitchKind | null } +export interface TrayProviderSource { name: string; path: string | null; switchKind: TraySwitchKind | null } export const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0; const object = (value: unknown): Record => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; // Read only the safe management projection; never retain configuration credentials. export function providerSources(value: unknown): TrayProviderSource[] { - return Object.entries(object(object(value).providers)).flatMap(([name, raw]) => { + return Object.entries(object(object(value).providers)).flatMap(([name, raw]) => { const config = object(raw); if (config.disabled === true) return []; - const path = name === 'openai' ? '/api/codex-auth/accounts' - : config.authMode === 'oauth' ? `/api/oauth/accounts?${new URLSearchParams({ provider: name, quota: '1' })}` - : config.hasApiKey === true && config.authMode !== 'forward' ? `/api/providers/keys?${new URLSearchParams({ name, quota: '1' })}` : null; - return [{ name, path }]; + if (name === 'openai') return [{ name, path: '/api/codex-auth/accounts', switchKind: 'codex' as const }]; + if (config.authMode === 'oauth') return [{ name, path: `/api/oauth/accounts?${new URLSearchParams({ provider: name, quota: '1' })}`, switchKind: 'oauth' as const }]; + if (config.hasApiKey === true && config.authMode !== 'forward') return [{ name, path: `/api/providers/keys?${new URLSearchParams({ name, quota: '1' })}`, switchKind: 'apiKey' as const }]; + return [{ name, path: null, switchKind: null }]; }); } +/** The management request that makes `accountId` active; the same routes and bodies the native panel and dashboard use. */ +export function accountSwitchRequest(provider: string, kind: TraySwitchKind, accountId: string): { path: string; body: Record } { + if (kind === 'codex') return { path: '/api/codex-auth/active', body: { accountId } }; + if (kind === 'oauth') return { path: '/api/oauth/accounts/active', body: { provider, accountId } }; + return { path: '/api/providers/keys/active', body: { name: provider, id: accountId } }; +} + +function switchState(row: Record, active: boolean): Pick { + if (active) return { switchState: 'active' }; + if (object(row.mainAccountHardLock).state === 'blocked') return { switchState: 'blocked', blockedReason: 'mainHardLock' }; + if (row.paused === true) return { switchState: 'blocked', blockedReason: 'paused' }; + if (object(row.health).reason === 'validation_pending') return { switchState: 'blocked', blockedReason: 'validationPending' }; + return { switchState: 'available' }; +} + +/** Same windows as `isCodexQuotaExhausted`: 100% in a window the plan is governed by, or in the burst window. */ +function exhausted(quota: Record, plan: string | undefined): boolean { + const monthlyOnly = ['go', 'free'].includes((plan ?? '').trim().toLowerCase()); + const short = quota.fiveHourPercent ?? quota.shortPercent; + const windows = monthlyOnly ? [quota.monthlyPercent, short] : [quota.weeklyPercent, quota.monthlyPercent, short]; + return windows.some(value => typeof value === 'number' && Number.isFinite(value) && value >= 100); +} + export function parseAccounts(value: unknown): TrayAccount[] { const body = object(value); const rows = body.accounts ?? body.keys; @@ -36,7 +66,8 @@ export function parseAccounts(value: unknown): TrayAccount[] { const quota = row.quotaUnavailable === true || row.quotaMode === 'unsupported' || !row.quota ? null : object(row.quota) as unknown as AccountQuota; const plan = typeof row.plan === 'string' ? row.plan : undefined; const activeId = body.activeAccountId ?? body.activeId ?? body.activeCodexAccountId; - return { id: row.id, label, email, plan, unavailable: row.quotaUnavailable === true, active: typeof activeId === 'string' ? activeId === row.id : row.active === true, status: typeof object(row.health).status === 'string' ? object(row.health).status as string : undefined, quotaFailure: typeof row.quotaFailure === 'string' ? row.quotaFailure : undefined, quota: normalizeQuotaForPlan(quota, plan) }; + const active = typeof activeId === 'string' ? activeId === row.id : row.active === true; + return { id: row.id, label, email, plan, unavailable: row.quotaUnavailable === true, active, ...switchState(row, active), exhausted: quota !== null && exhausted(object(row.quota), plan), status: typeof object(row.health).status === 'string' ? object(row.health).status as string : undefined, quotaFailure: typeof row.quotaFailure === 'string' ? row.quotaFailure : undefined, quota: normalizeQuotaForPlan(quota, plan) }; }); } diff --git a/gui/src/pages/tray.css b/gui/src/pages/tray.css index 9b969780eb4..2b78372b10e 100644 --- a/gui/src/pages/tray.css +++ b/gui/src/pages/tray.css @@ -25,6 +25,8 @@ html.tray-document { --tray-fill: rgba(235, 235, 245, 0.11); --tray-fill-strong: rgba(235, 235, 245, 0.18); --tray-accent: #32d74b; + --tray-warn: #ffd60a; + --tray-critical: #ff453a; --tray-alert: #ff9f8f; --tray-radius: 12px; --tray-numerals: ui-rounded, 'SF Pro Rounded', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; @@ -147,7 +149,10 @@ html.tray-document[data-tray-vibrancy='on'] { } .tray-provider + .tray-provider { margin-top: 12px; } -.tray-provider h2 { margin-bottom: 4px; } +.tray-provider h2 { margin-bottom: 4px; display: flex; align-items: center; gap: 6px; } +/* The dashboard's own provider mark, shrunk to the heading; the native panel shows the same file. */ +.tray-provider-icon.provider-icon { width: 18px; height: 18px; border-radius: 4px; } +.tray-provider-icon img, .tray-provider-icon .provider-icon-mask { width: 12px; height: 12px; } .tray-account { padding-left: 10px; border-left: 1px solid var(--tray-separator); margin-top: 7px; } .tray-account-name { display: flex; @@ -161,6 +166,15 @@ html.tray-document[data-tray-vibrancy='on'] { margin-bottom: 4px; } .tray-account-meta { flex-shrink: 0; color: var(--tray-label-tertiary); font-size: 10px; } +.tray-account-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.tray-active { color: var(--tray-accent); } +.tray-exhausted { color: var(--tray-warn); } +/* "Use" appears on hover or keyboard focus, as in the native panel, and keeps its space so the + row never shifts; screens without hover always show it. */ +.tray-page .tray-use { opacity: 0; margin-right: 6px; padding: 0 5px; font-size: 10px; text-decoration: none; border: 1px solid var(--tray-separator); color: var(--tray-label-secondary); } +.tray-account:hover .tray-use, .tray-page .tray-use:focus-visible { opacity: 1; } +.tray-page .tray-use:disabled { cursor: default; } +@media (hover: none) { .tray-page .tray-use { opacity: 1; } } .tray-account-email { color: var(--tray-label-tertiary); font-size: 10px; margin: -2px 0 4px; } .tray-quota { display: grid; grid-template-columns: 94px 32px minmax(35px, 1fr) 78px; align-items: center; gap: 7px; margin-top: 4px; font-size: 10px; } @@ -169,6 +183,9 @@ html.tray-document[data-tray-vibrancy='on'] { .tray-quota time { text-align: right; white-space: nowrap; color: var(--tray-label-tertiary); overflow: hidden; text-overflow: ellipsis; } .tray-bar { height: 4px; background: var(--tray-fill-strong); overflow: hidden; border-radius: 2px; } .tray-bar i { display: block; height: 100%; background: var(--tray-accent); border-radius: inherit; } +/* The dashboard strip's thresholds: 70% warns, 90% is critical. */ +.tray-bar--warn i { background: var(--tray-warn); } +.tray-bar--critical i { background: var(--tray-critical); } .tray-missing { color: var(--tray-label-tertiary); font-size: 11px; } .tray-error { color: var(--tray-alert); font-size: 11px; } diff --git a/gui/tests/tray-data.test.ts b/gui/tests/tray-data.test.ts index e0d41a8e55b..b4392b0aa67 100644 --- a/gui/tests/tray-data.test.ts +++ b/gui/tests/tray-data.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { fetchTrayJson, parseTrayUsage, filterUsage, measuredTotals, parseAccounts, providerSources, quotaWindows, relativeReset, resetTimestamp } from '../src/pages/tray-data'; +import { accountSwitchRequest, fetchTrayJson, parseTrayUsage, filterUsage, measuredTotals, parseAccounts, providerSources, quotaWindows, relativeReset, resetTimestamp } from '../src/pages/tray-data'; import type { CompanionSettings } from '../src/pages/usage-companion-utils'; describe('tray data', () => { @@ -93,3 +93,35 @@ test('tray fetch works without AbortSignal static helpers and forwards cancellat Object.defineProperty(AbortSignal, 'timeout', timeout); } }); + +describe('tray account switching (parity with the native panel)', () => { + test('each source names the switch route the native panel uses, and forward sources have none', () => { + const sources = providerSources({ providers: { openai: {}, claude: { authMode: 'oauth' }, key: { hasApiKey: true }, fwd: { hasApiKey: true, authMode: 'forward' } } }); + expect(sources.map(s => [s.name, s.switchKind])).toEqual([['openai', 'codex'], ['claude', 'oauth'], ['key', 'apiKey'], ['fwd', null]]); + expect(accountSwitchRequest('openai', 'codex', 'a1')).toEqual({ path: '/api/codex-auth/active', body: { accountId: 'a1' } }); + expect(accountSwitchRequest('claude', 'oauth', 'a2')).toEqual({ path: '/api/oauth/accounts/active', body: { provider: 'claude', accountId: 'a2' } }); + expect(accountSwitchRequest('key', 'apiKey', 'k3')).toEqual({ path: '/api/providers/keys/active', body: { name: 'key', id: 'k3' } }); + }); + + test('only what the switch route refuses is blocked; an exhausted account stays switchable', () => { + const rows = parseAccounts({ activeAccountId: 'on', accounts: [ + { id: 'on', quota: { weeklyPercent: 10 } }, + { id: 'lock', mainAccountHardLock: { state: 'blocked' }, quota: { weeklyPercent: 98 } }, + { id: 'paused', paused: true, quota: { weeklyPercent: 1 } }, + { id: 'pending', health: { reason: 'validation_pending' }, quota: { weeklyPercent: 1 } }, + { id: 'spent', quota: { weeklyPercent: 100 } }, + { id: 'burst', plan: 'go', quota: { weeklyPercent: 100, fiveHourPercent: 40, monthlyPercent: 20 } }, + { id: 'short', quota: { shortPercent: 100, weeklyPercent: 20 } }, + ] }); + expect(rows.map(r => [r.id, r.switchState, r.blockedReason ?? null, r.exhausted])).toEqual([ + ['on', 'active', null, false], + ['lock', 'blocked', 'mainHardLock', false], + ['paused', 'blocked', 'paused', false], + ['pending', 'blocked', 'validationPending', false], + ['spent', 'available', null, true], + // A monthly-only plan is not governed by its weekly window. + ['burst', 'available', null, false], + ['short', 'available', null, true], + ]); + }); +}); diff --git a/src/client/link-relay.ts b/src/client/link-relay.ts index 351abefbd31..41fcd0f8eb2 100644 --- a/src/client/link-relay.ts +++ b/src/client/link-relay.ts @@ -39,6 +39,19 @@ export interface LinkTunnelGate { waitForConnected(timeoutMs: number, signal?: AbortSignal): Promise; } +/** + * The gate for a Home-initiated link. The Home runs `ssh -R` and owns the forward, so the Child + * has no tunnel supervisor and no local SSH process whose socket it could prove. This gate keeps + * the 2.67.0 behaviour for that link only: forward to the tunnel port without an ownership proof, + * never hold, and answer 503 when the connection is refused. Child-initiated links keep their + * supervisor, and a relay with no gate at all still refuses every request. + */ +export const HOME_INITIATED_LINK_TUNNEL: LinkTunnelGate = { + connected: () => true, + pending: () => false, + waitForConnected: async () => false, +}; + export interface LinkRelayDeps { fetchImpl?: typeof fetch; clock?: LinkRelayClock; diff --git a/src/client/link-state.ts b/src/client/link-state.ts index c81a1c0c951..180b79fc775 100644 --- a/src/client/link-state.ts +++ b/src/client/link-state.ts @@ -38,6 +38,52 @@ export function clientLinkStatePath(configDir?: string): string { return join(linkDir(configDir), "client-link.json"); } +/** + * Durable evidence that this machine joined its link itself. It survives a lost sidecar, so a + * Child-initiated link whose sidecar is gone fails closed instead of being mistaken for a + * Home-initiated one, which the relay forwards without a tunnel ownership proof. + */ +export function childLinkMarkerPath(configDir?: string): string { + return join(linkDir(configDir), "child-initiated.json"); +} + +/** True when the marker names `linkId`, or when a marker exists but cannot be read. */ +export function isChildInitiatedLink(linkId: string, path: string = childLinkMarkerPath()): boolean { + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch (error) { + return !isMissingPathError(error); + } + try { + const raw = JSON.parse(text) as { linkId?: unknown }; + return typeof raw?.linkId !== "string" || raw.linkId === linkId; + } catch { + return true; + } +} + +/** + * Record the marker for a join made before markers existed. Called at runtime start while the + * sidecar is still intact, so a sidecar deleted afterwards cannot turn that link into a + * Home-initiated one. A missing or unreadable sidecar records nothing. + */ +export function recordChildInitiatedLink( + linkId: string, + path: string = clientLinkStatePath(), + marker: string = join(dirname(path), "child-initiated.json"), +): void { + let sidecar: ClientLinkState | null; + try { + sidecar = readClientLinkState(path); + } catch { + return; + } + if (!sidecar || sidecar.linkId !== linkId || isChildInitiatedLink(linkId, marker)) return; + atomicWriteFile(marker, `${JSON.stringify({ linkId })}\n`); + if (process.platform !== "win32") chmodSync(marker, 0o600); +} + export function parseClientLinkState(value: unknown): ClientLinkState { if (!value || typeof value !== "object" || Array.isArray(value)) throw new ClientLinkStateError("client-link.json is not an object"); const raw = value as Record; @@ -91,6 +137,9 @@ export function writeClientLinkState(state: ClientLinkState, path: string = clie // the explicit mode on the final path. atomicWriteFile(path, `${JSON.stringify(normalized, null, 2)}\n`); if (process.platform !== "win32") chmodSync(path, 0o600); + const marker = join(dir, "child-initiated.json"); + atomicWriteFile(marker, `${JSON.stringify({ linkId: normalized.linkId })}\n`); + if (process.platform !== "win32") chmodSync(marker, 0o600); } /** @@ -106,5 +155,10 @@ export function clearClientLinkState(expectedLinkId: string, path: string = clie if (isMissingPathError(error)) return false; throw error; } + try { + unlinkSync(join(dirname(path), "child-initiated.json")); + } catch (error) { + if (!isMissingPathError(error)) throw error; + } return true; } diff --git a/src/client/runtime.ts b/src/client/runtime.ts index 4667bb342a9..fa87ab20b4a 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -17,8 +17,9 @@ import { findAvailablePort, isAddrInUse, PortUnavailableError, waitForPortAvaila import type { ReplacementStartRequest } from "../server/restart-replacement"; import type { OcxClientConnectionConfig } from "../types"; import { createLinkKeySource } from "./link-ingress"; +import { HOME_INITIATED_LINK_TUNNEL } from "./link-relay"; import { createClientLinkSupervisor, type ClientLinkSupervisor } from "./link-tunnel"; -import { clientLinkStatePath } from "./link-state"; +import { clientLinkStatePath, isChildInitiatedLink, recordChildInitiatedLink } from "./link-state"; import { startMachineListener, type MachineListenerDeps } from "./machine-listener"; import { isLinkConnection, readClientConnectionState } from "./state"; @@ -278,12 +279,21 @@ export async function startClientRuntime( const preferred = linkMode ? config.port : options.port ?? config.port ?? 10100; // Share one cached key source between the listener and its tunnel supervisor. const linkKey = linkMode ? createLinkKeySource(state.value.tokenFingerprint) : undefined; + // Joins made before the marker existed get one now, while their sidecar is still here. + if (linkMode && state.value.link) { + try { recordChildInitiatedLink(state.value.link.linkId); } catch { /* the sidecar check below still applies */ } + } const supervisor = linkMode && existsSync(clientLinkStatePath()) ? createClientLinkSupervisor({ onLinkEnded: () => scheduleStandaloneRecycle(state.value.tokenFingerprint), linkKey, }) : null; + // A Child with no sidecar and no record of joining itself was connected by its Home over + // `ssh -R`; that link keeps 2.67.0's unproven forward. A Child-initiated link that lost its + // sidecar gets no gate at all, so the relay refuses it. + const homeInitiated = linkMode && !supervisor && !!state.value.link + && !isChildInitiatedLink(state.value.link.linkId); const { server, port: boundPort } = await bindClientListener({ state: state.value, linkMode, @@ -291,7 +301,7 @@ export async function startClientRuntime( explicitPort: options.port !== undefined, configuredPort: config.port, ...(linkMode ? { linkStatus: () => supervisor?.status() ?? { kind: "stopped" as const }, linkKeySource: linkKey } : {}), - ...(supervisor ? { linkTunnel: supervisor } : {}), + ...(supervisor ? { linkTunnel: supervisor } : homeInitiated ? { linkTunnel: HOME_INITIATED_LINK_TUNNEL } : {}), }, io); activeServer = server; activePort = boundPort; diff --git a/src/providers/kiro-model-catalog.ts b/src/providers/kiro-model-catalog.ts index 183c5f81f4f..2fb0d4ff2cc 100644 --- a/src/providers/kiro-model-catalog.ts +++ b/src/providers/kiro-model-catalog.ts @@ -19,6 +19,8 @@ interface Row { identity: string; models: KiroAccountModel[]; observedAt: number interface Flight { identity: string; promise: Promise } const rows = new Map(); const flights = new Map(); +/** Retry time after a failed discovery for an account that has no last good row to carry it. */ +const failedUntil = new Map(); export function kiroModelDiscoveryEnabled(): boolean { return process.env.OPENCODEX_KIRO_MODEL_DISCOVERY !== "0"; @@ -77,6 +79,8 @@ export function refreshKiroAccountModelsDetached( if (currentIdentity(account.id) !== identity) return; const old = validRow(account); if (old && Date.now() < old.nextRefreshAt) return; + const failed = failedUntil.get(account.id); + if (!old && failed?.identity === identity && Date.now() < failed.at) return; if (flights.get(account.id)?.identity === identity) return; const flight = (async (): Promise => { @@ -107,9 +111,14 @@ export function refreshKiroAccountModelsDetached( } if (currentIdentity(account.id) !== identity) return; const now = Date.now(); - if (fresh) rows.set(account.id, { identity, models: fresh, observedAt: now, - nextRefreshAt: now + KIRO_MODEL_CATALOG_TTL_MS }); - else if (old) rows.set(account.id, { ...old, nextRefreshAt: now + FAILURE_RETRY_MS }); + if (fresh) { + rows.set(account.id, { identity, models: fresh, observedAt: now, + nextRefreshAt: now + KIRO_MODEL_CATALOG_TTL_MS }); + failedUntil.delete(account.id); + } else if (old) rows.set(account.id, { ...old, nextRefreshAt: now + FAILURE_RETRY_MS }); + // Without a last good row the failure still has to back off, or every serving request + // after a restart would start another discovery while the endpoint is failing. + else failedUntil.set(account.id, { identity, at: now + FAILURE_RETRY_MS }); })(); flights.set(account.id, { identity, promise: flight }); void flight.catch(() => {}).finally(() => { @@ -143,8 +152,8 @@ export function kiroObservedContextWindow(model: string): number | undefined { } export function clearKiroAccountModels(accountId?: string): void { - if (accountId) { rows.delete(accountId); flights.delete(accountId); } - else { rows.clear(); flights.clear(); } + if (accountId) { rows.delete(accountId); flights.delete(accountId); failedUntil.delete(accountId); } + else { rows.clear(); flights.clear(); failedUntil.clear(); } } /** Deterministic test seam; production requests never call this. */ diff --git a/structure/companion.md b/structure/companion.md index 74e667c96ea..386a856edaa 100644 --- a/structure/companion.md +++ b/structure/companion.md @@ -77,6 +77,8 @@ and `exhausted` follows `isCodexQuotaExhausted` (100% in a governing window or t The "Use" action (`app/Sources/NativeTray/AccountSwitch.swift`) appears on hover, keyboard focus and as an accessibility action; an exhausted account stays switchable with a warning. +The web tray (`gui/src/pages/Tray.tsx`, the Windows and Linux popup) shows the same account state. `gui/src/pages/tray-data.ts` mirrors the native projection: `providerSources` names each provider's switch kind from the same config rules, `parseAccounts` derives `switchState`, `blockedReason` and `exhausted` with the same rules, and `accountSwitchRequest` builds the same route and body. The popup sends it with the dashboard session instead of the desktop capability, then reloads. Provider headings use the dashboard's `ProviderIcon`, and bars use `quotaSeverity` (warn 70%, critical 90%). `gui/tests/tray-data.test.ts` pins the parity. + The Tauri title reads `usage_today()`, matching the widget and retained Swift client. Every refresh applies the resulting optional title so icon-only clears an old counter. A nonblank custom template takes precedence over icon-only; unavailable measurements render as an em dash, not as a request diff --git a/structure/remote-link.md b/structure/remote-link.md index 81d52007e08..18bf22612e9 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -44,7 +44,7 @@ Applying a link probes the host key into a temporary file, waits for the operato ## Client link transport -The Child relay requires a positive `connected()` verdict from `src/client/link-tunnel.ts` before every fetch. Before the first keyed readiness probe and after a tunnel restart, the supervisor asynchronously proves the exact `127.0.0.1:` LISTEN socket belongs to its SSH child; an adopted process also needs matching pidfile argv and start time. `src/server/port-reclaim.ts` uses Linux `/proc/net/tcp{,6}` plus the expected PID's `/proc//fd` socket symlinks without external tools, macOS `lsof` and Windows `netstat` with bounded asynchronous execution. Every key-bearing probe and relayed request makes a fresh bounded asynchronous owner lookup; an adopted process also has its current argv and start time rechecked on every admission. An unreadable or timed-out identity denies only that admission and is retried; a confirmed mismatch releases the adopted PID without signalling it and lets the next tick start a fresh tunnel. An unknown socket-owner lookup likewise denies the current admission. A missing supervisor, or a failed or stopped tunnel, returns a retryable 503 without sending the link key or body to the persisted loopback port. A held request rechecks the verdict after its wait and before each retry. +The Child relay requires a positive `connected()` verdict from `src/client/link-tunnel.ts` before every fetch. Before the first keyed readiness probe and after a tunnel restart, the supervisor asynchronously proves the exact `127.0.0.1:` LISTEN socket belongs to its SSH child; an adopted process also needs matching pidfile argv and start time. `src/server/port-reclaim.ts` uses Linux `/proc/net/tcp{,6}` plus the expected PID's `/proc//fd` socket symlinks without external tools, macOS `lsof` and Windows `netstat` with bounded asynchronous execution. Every key-bearing probe and relayed request makes a fresh bounded asynchronous owner lookup; an adopted process also has its current argv and start time rechecked on every admission. An unreadable or timed-out identity denies only that admission and is retried; a confirmed mismatch releases the adopted PID without signalling it and lets the next tick start a fresh tunnel. An unknown socket-owner lookup likewise denies the current admission. A missing supervisor, or a failed or stopped tunnel, returns a retryable 503 without sending the link key or body to the persisted loopback port. A Home-initiated Child is the exception: the Home owns that link's `ssh -R` forward, so the Child has no sidecar, no supervisor and no SSH process whose socket it could prove. A join writes `/link/child-initiated.json` (the link id, mode 0600) beside the sidecar and removes it with the sidecar, so a Child-initiated link that lost its sidecar is recognised (`isChildInitiatedLink` in `src/client/link-state.ts`; an unreadable marker counts as present) and gets no gate. Only a link-mode runtime with neither the sidecar nor a marker for its link id is treated as Home-initiated: `src/client/runtime.ts` passes it `HOME_INITIATED_LINK_TUNNEL` (`src/client/link-relay.ts`), which keeps the 2.67.0 behaviour for that link only: forward without an ownership proof, never hold, and answer 503 at once when the connection is refused. The race recorded below therefore still applies in full to Home-initiated links. A held request rechecks the verdict after its wait and before each retry. The listener can change between an ownership check and the TCP connect. This is a pre-existing race class: since #5801/#5818 the Child relay has sent the link key to 127.0.0.1: without an ownership check. Future hardening on Unix can forward through a socket in a private mode-0700 directory (ssh -L /sock:...), removing the competing TCP listener from that path. diff --git a/tests/clients/client-link-relay.test.ts b/tests/clients/client-link-relay.test.ts index 2aae2d60503..a8cd6fce202 100644 --- a/tests/clients/client-link-relay.test.ts +++ b/tests/clients/client-link-relay.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import type { Server } from "bun"; import { forwardLinkRequestHeaders, + HOME_INITIATED_LINK_TUNNEL, LINK_RELAY_BODY_MAX_BYTES, LINK_RELAY_HEADER_TIMEOUT_MS, LINK_RELAY_HOLD_MS, @@ -474,6 +475,25 @@ describe("client link relay while the tunnel reconnects", () => { }); } + test("a Home-initiated link forwards like 2.67.0 and never holds a refused request", async () => { + // The Home owns the `ssh -R` forward, so this Child has no supervisor to prove it. The explicit + // Home-initiated gate forwards (a missing gate above still refuses); a refused connection is + // answered at once with a retryable 503 instead of waiting for a reconnect nobody drives. + let sends = 0; + const ok = await relayLinkDataRequestImpl(relayRequest({ method: "POST", body: "{}" }), target, { + tunnel: HOME_INITIATED_LINK_TUNNEL, + fetchImpl: (async () => { sends += 1; return Response.json({ forwarded: true }); }) as typeof fetch, + }); + expect(ok.status).toBe(200); + expect(sends).toBe(1); + const refused = await relayLinkDataRequestImpl(relayRequest({ method: "POST", body: "{}" }), target, { + tunnel: HOME_INITIATED_LINK_TUNNEL, + fetchImpl: (async () => { sends += 1; throw new Error("connection refused"); }) as typeof fetch, + }); + expect(refused.status).toBe(503); + expect(sends).toBe(2); + }); + test("rechecks connected state before retrying a refused fetch", async () => { let connected = true; let pending = false; diff --git a/tests/clients/client-link-state.test.ts b/tests/clients/client-link-state.test.ts index 17cada029b9..874564319d9 100644 --- a/tests/clients/client-link-state.test.ts +++ b/tests/clients/client-link-state.test.ts @@ -1,8 +1,11 @@ import { afterEach, expect, test } from "bun:test"; -import { existsSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { createTempHome, type TempHome } from "../helpers/temp-home"; import { + childLinkMarkerPath, clearClientLinkState, + isChildInitiatedLink, + recordChildInitiatedLink, clientLinkStatePath, ClientLinkStateError, readClientLinkState, @@ -66,3 +69,34 @@ test("client link sidecar clear is owner checked", () => { expect(existsSync(path)).toBe(false); expect(clearClientLinkState(fixture().linkId, path)).toBe(false); }); + +test("a join leaves a marker that outlives a lost sidecar and is removed with it", () => { + // Without the marker a Child-initiated link whose sidecar went missing would look + // Home-initiated, and the relay would forward it without a tunnel ownership proof. + home = createTempHome("ocx-client-link-marker-"); + const path = clientLinkStatePath(home.configDir); + const marker = childLinkMarkerPath(home.configDir); + expect(isChildInitiatedLink("lnk_0123456789abcdef", marker)).toBe(false); + writeClientLinkState(fixture(), path); + expect(isChildInitiatedLink("lnk_0123456789abcdef", marker)).toBe(true); + expect(isChildInitiatedLink("lnk_fedcba9876543210", marker)).toBe(false); + if (process.platform !== "win32") expect(statSync(marker).mode & 0o777).toBe(0o600); + expect(clearClientLinkState("lnk_0123456789abcdef", path)).toBe(true); + expect(existsSync(marker)).toBe(false); + // An unreadable marker fails closed. + writeFileSync(marker, "{not json"); + expect(isChildInitiatedLink("lnk_fedcba9876543210", marker)).toBe(true); +}); + +test("a join made before markers existed gets one at start while its sidecar is intact", () => { + home = createTempHome("ocx-client-link-legacy-"); + const path = clientLinkStatePath(home.configDir); + const marker = childLinkMarkerPath(home.configDir); + writeClientLinkState(fixture(), path); + unlinkSync(marker); // what a 2.67.0 join left behind + recordChildInitiatedLink("lnk_fedcba9876543210", path, marker); + expect(existsSync(marker)).toBe(false); // another link's id records nothing + recordChildInitiatedLink("lnk_0123456789abcdef", path, marker); + unlinkSync(path); + expect(isChildInitiatedLink("lnk_0123456789abcdef", marker)).toBe(true); +}); diff --git a/tests/providers/kiro/kiro-model-catalog.test.ts b/tests/providers/kiro/kiro-model-catalog.test.ts index 6dcd554d31e..19892a3c899 100644 --- a/tests/providers/kiro/kiro-model-catalog.test.ts +++ b/tests/providers/kiro/kiro-model-catalog.test.ts @@ -265,3 +265,28 @@ test("the roster adds at most 64 observed ids to the catalog", async () => { expect(result.models).toHaveLength(1 + 64); }); +test("a first discovery failure backs off even with no last good list", async () => { + // After a restart there is no cached row to carry the retry time, so a failing endpoint + // must still be tried once per retry window rather than once per serving request. + setup(); + const account = await add("fresh"); + let calls = 0; + const failing = { + resolveAddresses: async (url: string) => ({ hostname: new URL(url).hostname, + addresses: [{ address: "1.1.1.1", family: 4 }], privateNetwork: false }), + pinnedPost: async () => { calls++; return new Response("unavailable", { status: 503 }); }, + } as never; + refreshKiroAccountModelsDetached(account, provider, failing); + await awaitKiroModelRefreshForTests(account.id); + await Bun.sleep(1); // let the finished flight leave the join table + refreshKiroAccountModelsDetached(account, provider, failing); + await awaitKiroModelRefreshForTests(account.id); + await Bun.sleep(1); // let the finished flight leave the join table + expect(calls).toBe(1); + expect(readKiroAccountModels(account)).toBeUndefined(); + clearKiroAccountModels(account.id); + refreshKiroAccountModelsDetached(account, provider, failing); + await awaitKiroModelRefreshForTests(account.id); + await Bun.sleep(1); // let the finished flight leave the join table + expect(calls).toBe(2); +}); diff --git a/tests/responses/responses-grok-devin-preflight.test.ts b/tests/responses/responses-grok-devin-preflight.test.ts index 0647cfbd4c2..78d3bc5740d 100644 --- a/tests/responses/responses-grok-devin-preflight.test.ts +++ b/tests/responses/responses-grok-devin-preflight.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, mock, test } from "bun:test"; +import { afterAll, afterEach, beforeEach, expect, mock, test } from "bun:test"; import type { ProviderAdapter } from "../../src/adapters/base"; import type { AdapterEvent, OcxConfig, OcxProviderConfig } from "../../src/types"; import { saveCredential } from "../../src/oauth/store"; @@ -9,6 +9,8 @@ import { createTempHome } from "../helpers/temp-home"; const resolver = await import("../../src/server/adapter-resolve"); const originalResolve = resolver.resolveAdapter; +// A snapshot, not the live namespace: mock.module rewrites that namespace in place. +const originalResolverModule = { ...resolver }; let events: AdapterEvent[] = []; let calls = 0; let blockedRun: ProviderAdapter["runTurn"]; @@ -46,6 +48,11 @@ beforeEach(async () => { expires: Date.now() + 3_600_000, accountId: "fixture", }); }); +// Bun keeps a module mock for the rest of the process. Without this, every later file in a +// non-isolated run resolves Devin through the synthetic rate-limited adapter above. +afterAll(() => { + mock.module("../../src/server/adapter-resolve", () => originalResolverModule); +}); afterEach(() => { try { release?.();