From 9f0aa976cd03da1f24dd744045c75070a6e83116 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 06:27:41 +0900 Subject: [PATCH] fix(subagents): carry entitlement into the recovery preview (#2509) #2515 fixed the primary selection path to preview per candidate quota scope, and #2623 added the entitled-account filter there. The encrypted-recovery path got the scope but not the filter: it re-previewed per candidate and passed no eligible-account set, so a recovered assignment could select an account with no entitlement to the model and fail closed at final auth. Same stale-selection class as the quota scope, one layer over. Pinned structurally, like the route-inventory contract: both preview assignment sites must accept and forward modelEligibleAccountIds. Driving it end to end needs a recovered encrypted assignment AND an account-gated candidate whose entitlement differs per account, and that fixture proved more fragile than the thing it checks - I tried it and dropped it rather than ship a flaky test. What this does catch is the regression that actually threatens the fix: one of the two sites silently losing the argument again, which is how recovery lost it. Falsified: reverting the recovery site to the two-argument form reddens exactly this test. --- src/server/responses/core.ts | 16 +++++++-- ...subagent-fallback-handle-responses.test.ts | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index f9a5c204b9..1466e723aa 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -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, @@ -2647,6 +2656,7 @@ async function handleResponsesInner( false, recoverySelectionOptions, subagentFallbackAccountPreview, + subagentFallbackModelEligibleAccountIdsForModel, ); } finally { recoverySelectionAdmission?.release(); diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index d9c2e34964..384368eae7 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -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;