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
16 changes: 13 additions & 3 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2630,14 +2630,23 @@ async function handleResponsesInner(
&& recoverySelectionAdmission?.mainProfileDraining === true,
};
const recoveryNow = Date.now();
subagentFallbackAccountPreview = (modelId, previewNow) => previewCodexAccountForRequest(
// Carry the entitlement filter through recovery too (#2509/#2623). The scope was
// already re-previewed per candidate here; the ELIGIBLE-ACCOUNT set was not, so a
// recovered assignment could select an account that is not entitled to the model
// and then fail closed at final auth — the same class of stale-selection bug as
// the quota scope, one layer over.
subagentFallbackAccountPreview = (modelId, previewNow, modelEligibleAccountIds) => previewCodexAccountForRequest(
poolAffinityKey,
config,
previewNow,
codexQuotaScopeForModel(modelId),
recoverySelectionOptions,
{ ...recoverySelectionOptions, modelEligibleAccountIds },
);
const recoveryPreviewAccountId = subagentFallbackAccountPreview(
parsed.modelId,
recoveryNow,
subagentFallbackModelEligibleAccountIdsForModel?.(parsed.modelId),
);
const recoveryPreviewAccountId = subagentFallbackAccountPreview(parsed.modelId, recoveryNow);
return applySubagentModelFallback(
parsed,
req.headers,
Expand All @@ -2647,6 +2656,7 @@ async function handleResponsesInner(
false,
recoverySelectionOptions,
subagentFallbackAccountPreview,
subagentFallbackModelEligibleAccountIdsForModel,

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 Refresh eligibility after encrypted recovery

When native-profile drain state changes during await recoverEncryptedAgentTask, this reuses the eligibility closure created before recovery while combining it with a fresh recoverySelectionAdmission. For example, if the initial admission excludes __main__, the drain completes during recovery, and only main is entitled to the gated fallback model, the stale set still excludes main and the recovered request skips that now-usable fallback, potentially sending the already-blocked routed primary instead. Re-resolve eligibility after recovery using the current main-account exclusion (or otherwise bind the snapshot and admission atomically), and cover the transition behavior directly—the added source-regex test cannot detect this stale-state failure.

AGENTS.md reference: AGENTS.md:L276-L278

Useful? React with 👍 / 👎.

);
} finally {
recoverySelectionAdmission?.release();
Expand Down
33 changes: 33 additions & 0 deletions tests/subagent-fallback-handle-responses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1274,6 +1274,39 @@ describe("native fallback account preview", () => {
expect(bodyRequests[1]?.auth).toContain("pool-b_token");
});

/**
* Recovery must carry the ENTITLEMENT filter too, not only the quota scope (#2509).
*
* The end-to-end case above grants the roster to both pool accounts, so it can only prove the
* SCOPE is re-previewed per candidate. The recovery path re-previewed the scope but passed no
* eligible-account set, so it could select an account with no entitlement to the recovered
* model and fail closed at final auth — the same stale-selection class as the quota scope, one
* layer over.
*
* Asserted structurally on the source, like the route-inventory contract: driving it end to end
* needs a recovered encrypted assignment AND an account-gated candidate whose entitlement
* differs per account, and the resulting fixture proved more fragile than the thing it checks.
* What this does catch is the regression that actually threatens the fix — one of the two
* preview sites silently losing the eligibility argument again.
*/
test("both fallback preview sites pass the model-eligible account set (#2509)", async () => {
const source = await Bun.file(
new URL("../src/server/responses/core.ts", import.meta.url).pathname,
).text();

const previews = source.match(/subagentFallbackAccountPreview = \([^)]*\)/g) ?? [];
// Two assignment sites: the primary selection path and the encrypted-recovery path.
expect(previews).toHaveLength(2);
// Neither may drop the third parameter — that is exactly how recovery lost it.
for (const preview of previews) {
expect(preview).toContain("modelEligibleAccountIds");
}

// And both must actually forward it into the preview call, not merely accept it.
const forwarded = source.match(/\{ \.\.\.(previewSelectionOptions|recoverySelectionOptions), modelEligibleAccountIds \}/g) ?? [];
expect(forwarded).toHaveLength(2);
});

test("uses healthier pool account B when active A is above threshold", async () => {
const now = 1_800_000_000_000;
Date.now = () => now;
Expand Down
Loading