Skip to content

Commit 85877c1

Browse files
authored
Merge pull request #85 from dengerouzzz/main
fix: gracefully handle deactivated_workspace — remove only the dead workspace, preserve siblings
2 parents 8cd8574 + c0b51aa commit 85877c1

File tree

7 files changed

+666
-40
lines changed

7 files changed

+666
-40
lines changed

index.ts

Lines changed: 148 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ import {
117117
createTimestampedBackupPath,
118118
loadFlaggedAccounts,
119119
saveFlaggedAccounts,
120+
withFlaggedAccountStorageTransaction,
120121
clearFlaggedAccounts,
121122
StorageError,
122123
formatStorageErrorHint,
@@ -128,6 +129,7 @@ import {
128129
extractRequestUrl,
129130
handleErrorResponse,
130131
handleSuccessResponse,
132+
isDeactivatedWorkspaceError,
131133
getUnsupportedCodexModelInfo,
132134
resolveUnsupportedCodexFallbackModel,
133135
refreshAndUpdateToken,
@@ -183,6 +185,49 @@ import {
183185
getRecoveryToastContent,
184186
} from "./lib/recovery.js";
185187

188+
function getWorkspaceIdentityKey(account: {
189+
organizationId?: string;
190+
accountId?: string;
191+
refreshToken: string;
192+
}): string {
193+
const organizationId = account.organizationId?.trim();
194+
const accountId = account.accountId?.trim();
195+
const refreshToken = account.refreshToken.trim();
196+
if (organizationId) {
197+
return accountId
198+
? `organizationId:${organizationId}|accountId:${accountId}`
199+
: `organizationId:${organizationId}`;
200+
}
201+
if (accountId) return `accountId:${accountId}`;
202+
return `refreshToken:${refreshToken}`;
203+
}
204+
205+
function matchesWorkspaceIdentity(
206+
account: {
207+
organizationId?: string;
208+
accountId?: string;
209+
refreshToken: string;
210+
},
211+
identityKey: string,
212+
): boolean {
213+
return getWorkspaceIdentityKey(account) === identityKey;
214+
}
215+
216+
function upsertFlaggedAccountRecord(
217+
accounts: FlaggedAccountMetadataV1[],
218+
record: FlaggedAccountMetadataV1,
219+
): void {
220+
const identityKey = getWorkspaceIdentityKey(record);
221+
const existingIndex = accounts.findIndex((flagged) =>
222+
matchesWorkspaceIdentity(flagged, identityKey),
223+
);
224+
if (existingIndex >= 0) {
225+
accounts[existingIndex] = record;
226+
return;
227+
}
228+
accounts.push(record);
229+
}
230+
186231
/**
187232
* OpenAI Codex OAuth authentication plugin for opencode
188233
*
@@ -2358,6 +2403,59 @@ while (attempted.size < Math.max(1, accountCount)) {
23582403
threadId: threadIdCandidate,
23592404
});
23602405

2406+
const workspaceDeactivated = isDeactivatedWorkspaceError(errorBody, response.status);
2407+
if (workspaceDeactivated) {
2408+
const accountLabel = formatAccountLabel(account, account.index);
2409+
accountManager.refundToken(account, modelFamily, model);
2410+
accountManager.recordFailure(account, modelFamily, model);
2411+
account.lastSwitchReason = "rotation";
2412+
runtimeMetrics.failedRequests++;
2413+
runtimeMetrics.accountRotations++;
2414+
runtimeMetrics.lastError = `Deactivated workspace on ${accountLabel}`;
2415+
runtimeMetrics.lastErrorCategory = "workspace-deactivated";
2416+
2417+
try {
2418+
const flaggedRecord: FlaggedAccountMetadataV1 = {
2419+
...account,
2420+
flaggedAt: Date.now(),
2421+
flaggedReason: "workspace-deactivated",
2422+
lastError: "deactivated_workspace",
2423+
};
2424+
await withFlaggedAccountStorageTransaction(async (current, persist) => {
2425+
const nextStorage: typeof current = {
2426+
...current,
2427+
accounts: current.accounts.map((flagged) => ({ ...flagged })),
2428+
};
2429+
upsertFlaggedAccountRecord(nextStorage.accounts, flaggedRecord);
2430+
await persist(nextStorage);
2431+
});
2432+
} catch (flagError) {
2433+
logWarn(
2434+
`Failed to persist deactivated workspace flag for ${accountLabel}: ${flagError instanceof Error ? flagError.message : String(flagError)}`,
2435+
);
2436+
}
2437+
2438+
if (accountManager.removeAccount(account)) {
2439+
accountManager.saveToDiskDebounced();
2440+
attempted.clear();
2441+
accountCount = accountManager.getAccountCount();
2442+
await showToast(
2443+
`Workspace deactivated. Removed ${accountLabel} from rotation and switching accounts.`,
2444+
"warning",
2445+
{ duration: toastDurationMs },
2446+
);
2447+
break;
2448+
}
2449+
2450+
accountManager.markAccountCoolingDown(
2451+
account,
2452+
ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS,
2453+
"auth-failure",
2454+
);
2455+
accountManager.saveToDiskDebounced();
2456+
break;
2457+
}
2458+
23612459
const unsupportedModelInfo = getUnsupportedCodexModelInfo(errorBody);
23622460
const hasRemainingAccounts = attempted.size < Math.max(1, accountCount);
23632461

@@ -2964,12 +3062,18 @@ while (attempted.size < Math.max(1, accountCount)) {
29643062
(typeof (errorBody as { error?: { message?: unknown } })?.error?.message === "string"
29653063
? (errorBody as { error?: { message?: string } }).error?.message
29663064
: bodyText) || `HTTP ${response.status}`;
3065+
if (isDeactivatedWorkspaceError(errorBody, response.status)) {
3066+
throw new Error("deactivated_workspace");
3067+
}
29673068
throw new Error(message);
29683069
}
29693070

29703071
lastError = new Error("Codex response did not include quota headers");
29713072
} catch (error) {
29723073
lastError = error instanceof Error ? error : new Error(String(error));
3074+
if (lastError.message === "deactivated_workspace") {
3075+
throw lastError;
3076+
}
29733077
}
29743078
}
29753079

@@ -2993,9 +3097,9 @@ while (attempted.size < Math.max(1, accountCount)) {
29933097
return;
29943098
}
29953099

2996-
const flaggedStorage = await loadFlaggedAccounts();
29973100
let storageChanged = false;
29983101
let flaggedChanged = false;
3102+
const flaggedUpdates = new Map<string, FlaggedAccountMetadataV1>();
29993103
const removeFromActive = new Set<string>();
30003104
const total = workingStorage.accounts.length;
30013105
let ok = 0;
@@ -3101,21 +3205,17 @@ while (attempted.size < Math.max(1, accountCount)) {
31013205
refreshResult.message ?? refreshResult.reason ?? "refresh failed";
31023206
console.log(`[${i + 1}/${total}] ${label}: ERROR (${message})`);
31033207
if (deepProbe && isFlaggableFailure(refreshResult)) {
3104-
const existingIndex = flaggedStorage.accounts.findIndex(
3105-
(flagged) => flagged.refreshToken === account.refreshToken,
3106-
);
31073208
const flaggedRecord: FlaggedAccountMetadataV1 = {
31083209
...account,
31093210
flaggedAt: Date.now(),
31103211
flaggedReason: "token-invalid",
31113212
lastError: message,
31123213
};
3113-
if (existingIndex >= 0) {
3114-
flaggedStorage.accounts[existingIndex] = flaggedRecord;
3115-
} else {
3116-
flaggedStorage.accounts.push(flaggedRecord);
3117-
}
3118-
removeFromActive.add(account.refreshToken);
3214+
flaggedUpdates.set(
3215+
getWorkspaceIdentityKey(flaggedRecord),
3216+
flaggedRecord,
3217+
);
3218+
removeFromActive.add(getWorkspaceIdentityKey(account));
31193219
flaggedChanged = true;
31203220
}
31213221
continue;
@@ -3197,6 +3297,20 @@ while (attempted.size < Math.max(1, accountCount)) {
31973297
} catch (error) {
31983298
errors += 1;
31993299
const message = error instanceof Error ? error.message : String(error);
3300+
if (message === "deactivated_workspace") {
3301+
const flaggedRecord: FlaggedAccountMetadataV1 = {
3302+
...account,
3303+
flaggedAt: Date.now(),
3304+
flaggedReason: "workspace-deactivated",
3305+
lastError: message,
3306+
};
3307+
flaggedUpdates.set(
3308+
getWorkspaceIdentityKey(flaggedRecord),
3309+
flaggedRecord,
3310+
);
3311+
removeFromActive.add(getWorkspaceIdentityKey(account));
3312+
flaggedChanged = true;
3313+
}
32003314
console.log(
32013315
`[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 160)})`,
32023316
);
@@ -3210,7 +3324,7 @@ while (attempted.size < Math.max(1, accountCount)) {
32103324

32113325
if (removeFromActive.size > 0) {
32123326
workingStorage.accounts = workingStorage.accounts.filter(
3213-
(account) => !removeFromActive.has(account.refreshToken),
3327+
(account) => !removeFromActive.has(getWorkspaceIdentityKey(account)),
32143328
);
32153329
clampActiveIndices(workingStorage);
32163330
storageChanged = true;
@@ -3221,14 +3335,23 @@ while (attempted.size < Math.max(1, accountCount)) {
32213335
invalidateAccountManagerCache();
32223336
}
32233337
if (flaggedChanged) {
3224-
await saveFlaggedAccounts(flaggedStorage);
3338+
await withFlaggedAccountStorageTransaction(async (current, persist) => {
3339+
const nextStorage: typeof current = {
3340+
...current,
3341+
accounts: current.accounts.map((flagged) => ({ ...flagged })),
3342+
};
3343+
for (const flaggedRecord of flaggedUpdates.values()) {
3344+
upsertFlaggedAccountRecord(nextStorage.accounts, flaggedRecord);
3345+
}
3346+
await persist(nextStorage);
3347+
});
32253348
}
32263349

32273350
console.log("");
32283351
console.log(`Results: ${ok} ok, ${errors} error, ${disabled} disabled`);
32293352
if (removeFromActive.size > 0) {
32303353
console.log(
3231-
`Moved ${removeFromActive.size} account(s) to flagged pool (invalid refresh token).`,
3354+
`Moved ${removeFromActive.size} account(s) to flagged pool.`,
32323355
);
32333356
}
32343357
console.log("");
@@ -3249,6 +3372,13 @@ while (attempted.size < Math.max(1, accountCount)) {
32493372
const flagged = flaggedStorage.accounts[i];
32503373
if (!flagged) continue;
32513374
const label = flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`;
3375+
if (flagged.flaggedReason === "workspace-deactivated") {
3376+
console.log(
3377+
`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: STILL FLAGGED (workspace deactivated)`,
3378+
);
3379+
remaining.push(flagged);
3380+
continue;
3381+
}
32523382
try {
32533383
const cached = await lookupCodexCliTokensByEmail(flagged.email);
32543384
const now = Date.now();
@@ -3425,7 +3555,11 @@ while (attempted.size < Math.max(1, accountCount)) {
34253555
await saveFlaggedAccounts({
34263556
version: 1,
34273557
accounts: flaggedStorage.accounts.filter(
3428-
(flagged) => flagged.refreshToken !== target.refreshToken,
3558+
(flagged) =>
3559+
!matchesWorkspaceIdentity(
3560+
flagged,
3561+
getWorkspaceIdentityKey(target),
3562+
),
34293563
),
34303564
});
34313565
invalidateAccountManagerCache();

lib/request/fetch-helpers.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,35 @@ export interface ErrorDiagnostics {
279279
httpStatus?: number;
280280
}
281281

282+
const DEACTIVATED_WORKSPACE_CODE = "deactivated_workspace";
283+
284+
function getStructuredErrorCode(errorBody: unknown): string | undefined {
285+
if (!isRecord(errorBody)) return undefined;
286+
287+
const directCode = errorBody.code;
288+
if (typeof directCode === "string" && directCode.trim()) return directCode.trim();
289+
290+
const detail = errorBody.detail;
291+
if (isRecord(detail)) {
292+
const detailCode = detail.code;
293+
if (typeof detailCode === "string" && detailCode.trim()) return detailCode.trim();
294+
}
295+
296+
const nestedError = errorBody.error;
297+
if (isRecord(nestedError)) {
298+
const nestedCode = nestedError.code ?? nestedError.type;
299+
if (typeof nestedCode === "string" && nestedCode.trim()) return nestedCode.trim();
300+
}
301+
302+
return undefined;
303+
}
304+
305+
export function isDeactivatedWorkspaceError(errorBody: unknown, status?: number): boolean {
306+
if (status !== undefined && status !== 402) return false;
307+
const code = getStructuredErrorCode(errorBody);
308+
return code === DEACTIVATED_WORKSPACE_CODE;
309+
}
310+
282311
/**
283312
* Determines if the current auth token needs to be refreshed
284313
* @param auth - Current authentication state
@@ -730,6 +759,21 @@ function normalizeErrorPayload(
730759
status: number,
731760
diagnostics?: ErrorDiagnostics,
732761
): ErrorPayload {
762+
if (isDeactivatedWorkspaceError(errorBody, status)) {
763+
const payload: ErrorPayload = {
764+
error: {
765+
message:
766+
"The selected ChatGPT workspace is deactivated. This workspace entry should be removed from rotation or re-authorized before retrying.",
767+
type: "workspace_deactivated",
768+
code: DEACTIVATED_WORKSPACE_CODE,
769+
},
770+
};
771+
if (diagnostics && Object.keys(diagnostics).length > 0) {
772+
payload.error.diagnostics = diagnostics;
773+
}
774+
return payload;
775+
}
776+
733777
if (isUnsupportedCodexModelForChatGpt(status, bodyText)) {
734778
const unsupportedModel =
735779
extractUnsupportedCodexModelFromText(bodyText) ?? "requested model";

0 commit comments

Comments
 (0)