diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..feb8acb --- /dev/null +++ b/auth.ts @@ -0,0 +1,12 @@ +export async function retryInvalidToken< + T extends { status: { success: boolean }; error: string }, +>(attempt: () => Promise, relogin: () => Promise): Promise { + let result = await attempt(); + if ( + !result.status.success && result.error.includes("invalid_refresh_token") + ) { + await relogin(); + result = await attempt(); + } + return result; +} diff --git a/auth_test.ts b/auth_test.ts new file mode 100644 index 0000000..eaa5ca5 --- /dev/null +++ b/auth_test.ts @@ -0,0 +1,44 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import { retryInvalidToken } from "./auth.ts"; + +Deno.test("retries once after an invalid refresh token", async () => { + let attempts = 0; + let relogins = 0; + const result = await retryInvalidToken( + () => + Promise.resolve({ + status: { success: false }, + error: attempts++ === 0 ? "invalid_refresh_token" : "still failed", + }), + () => { + relogins++; + return Promise.resolve(); + }, + ); + + assertEquals({ attempts, relogins, error: result.error }, { + attempts: 2, + relogins: 1, + error: "still failed", + }); +}); + +Deno.test("does not retry an unrelated failure", async () => { + let attempts = 0; + let relogins = 0; + await retryInvalidToken( + () => { + attempts++; + return Promise.resolve({ + status: { success: false }, + error: "rate limited", + }); + }, + () => { + relogins++; + return Promise.resolve(); + }, + ); + + assertEquals({ attempts, relogins }, { attempts: 1, relogins: 0 }); +}); diff --git a/run.ts b/run.ts index 8218dc4..19e4979 100644 --- a/run.ts +++ b/run.ts @@ -3,6 +3,7 @@ // GITEA_TOKEN; everything this script posts goes through the reviewer token, // so it appears as the bot account. import { TextLineStream } from "jsr:@std/streams@1/text-line-stream"; +import { retryInvalidToken } from "./auth.ts"; type GiteaUser = { login: string; email: string }; type GiteaComment = { user: GiteaUser; created_at: string; body: string }; @@ -252,21 +253,35 @@ async function runCodex(prompt: string): Promise { .output(); if (!loggedIn.success) await codexDeviceLogin(); const file = await Deno.makeTempFile(); - const status = await new Deno.Command("codex", { - args: [ - "exec", - "--model", - model("codex"), - "--dangerously-bypass-approvals-and-sandbox", - "--output-last-message", - file, - prompt, - ], - env: agentEnv, - clearEnv: true, - stdout: "inherit", - stderr: "inherit", - }).spawn().status; + const attempt = async () => { + const codex = new Deno.Command("codex", { + args: [ + "exec", + "--model", + model("codex"), + "--dangerously-bypass-approvals-and-sandbox", + "--output-last-message", + file, + prompt, + ], + env: agentEnv, + clearEnv: true, + stdout: "inherit", + stderr: "piped", + }).spawn(); + const decoder = new TextDecoder(); + let error = ""; + for await (const chunk of codex.stderr) { + await Deno.stderr.write(chunk); + error += decoder.decode(chunk, { stream: true }); + } + error += decoder.decode(); + return { status: await codex.status, error }; + }; + const { status } = await retryInvalidToken(attempt, async () => { + await run("codex", ["logout"]); + await codexDeviceLogin(); + }); if (!status.success) throw new Error(`codex exited with ${status.code}`); return await Deno.readTextFile(file); }