diff --git a/packages/adapters/claude-local/src/server/parse.test.ts b/packages/adapters/claude-local/src/server/parse.test.ts index de9b304557..3f27e40ec3 100644 --- a/packages/adapters/claude-local/src/server/parse.test.ts +++ b/packages/adapters/claude-local/src/server/parse.test.ts @@ -97,22 +97,22 @@ describe("isClaudeTransientUpstreamError", () => { }); describe("extractClaudeRetryNotBefore", () => { - it("parses the 'resets 4pm' hint into an absolute retry time", () => { - const now = new Date(2026, 3, 22, 10, 15, 0); + it("parses the 'resets 4pm' hint in its explicit timezone", () => { + const now = new Date("2026-04-22T15:15:00.000Z"); const extracted = extractClaudeRetryNotBefore( { errorMessage: "You're out of extra usage · resets 4pm (America/Chicago)" }, now, ); - expect(extracted?.getTime()).toBe(new Date(2026, 3, 22, 16, 0, 0, 0).getTime()); + expect(extracted?.toISOString()).toBe("2026-04-22T21:00:00.000Z"); }); it("rolls forward past midnight when the reset time has already passed today", () => { - const now = new Date(2026, 3, 22, 23, 30, 0); + const now = new Date("2026-04-22T23:30:00.000Z"); const extracted = extractClaudeRetryNotBefore( { errorMessage: "Usage limit reached. Resets at 3:15 AM (UTC)." }, now, ); - expect(extracted?.getTime()).toBe(new Date(2026, 3, 23, 3, 15, 0, 0).getTime()); + expect(extracted?.toISOString()).toBe("2026-04-23T03:15:00.000Z"); }); it("returns null when no reset hint is present", () => { diff --git a/packages/adapters/claude-local/src/server/parse.ts b/packages/adapters/claude-local/src/server/parse.ts index cf633a0be3..4591aaad02 100644 --- a/packages/adapters/claude-local/src/server/parse.ts +++ b/packages/adapters/claude-local/src/server/parse.ts @@ -1,5 +1,10 @@ import type { UsageSummary } from "@paperclipai/adapter-utils"; -import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils"; +import { + asString, + asNumber, + parseObject, + parseJson, +} from "@paperclipai/adapter-utils/server-utils"; const CLAUDE_AUTH_REQUIRED_RE = /(?:not\s+logged\s+in|please\s+log\s+in|please\s+run\s+`?claude\s+login`?|login\s+required|requires\s+login|unauthorized|authentication\s+required)/i; const URL_RE = /(https?:\/\/[^\s'"`<>()[\]{};,!?]+[^\s'"`<>()[\]{};,!.?:]+)/gi; @@ -7,7 +12,7 @@ const URL_RE = /(https?:\/\/[^\s'"`<>()[\]{};,!?]+[^\s'"`<>()[\]{};,!.?:]+)/gi; const CLAUDE_TRANSIENT_UPSTREAM_RE = /(?:rate[-\s]?limit(?:ed)?|rate_limit_error|too\s+many\s+requests|\b429\b|overloaded(?:_error)?|server\s+overloaded|service\s+unavailable|\b503\b|\b529\b|high\s+demand|try\s+again\s+later|temporarily\s+unavailable|throttl(?:ed|ing)|throttlingexception|servicequotaexceededexception|out\s+of\s+extra\s+usage|extra\s+usage\b|claude\s+usage\s+limit\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached|usage\s+limit\s+reached|usage\s+cap\s+reached)/i; const CLAUDE_EXTRA_USAGE_RESET_RE = - /(?:out\s+of\s+extra\s+usage|extra\s+usage|usage\s+limit\s+reached|usage\s+cap\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached|claude\s+usage\s+limit\s+reached)[\s\S]{0,80}?\bresets?\s+(?:at\s+)?([^\n()]+?)(?:\s*\([^)]+\))?(?:[.!]|\n|$)/i; + /(?:out\s+of\s+extra\s+usage|extra\s+usage|usage\s+limit\s+reached|usage\s+cap\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached|claude\s+usage\s+limit\s+reached)[\s\S]{0,80}?\bresets?\s+(?:at\s+)?([^\n()]+?)(?:\s*\(([^)]+)\))?(?:[.!]|\n|$)/i; export function parseClaudeStreamJson(stdout: string) { let sessionId: string | null = null; @@ -206,7 +211,109 @@ function buildClaudeTransientHaystack(input: { .join("\n"); } -function parseClaudeResetClockTime(clockText: string, now: Date): Date | null { +function readTimeZoneParts(date: Date, timeZone: string) { + const values = new Map( + new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).formatToParts(date).map((part) => [part.type, part.value]), + ); + return { + year: Number.parseInt(values.get("year") ?? "", 10), + month: Number.parseInt(values.get("month") ?? "", 10), + day: Number.parseInt(values.get("day") ?? "", 10), + hour: Number.parseInt(values.get("hour") ?? "", 10), + minute: Number.parseInt(values.get("minute") ?? "", 10), + }; +} + +function normalizeResetTimeZone(timeZoneHint: string | null | undefined): string | null { + const normalized = timeZoneHint?.trim(); + if (!normalized) return null; + if (/^(?:utc|gmt)$/i.test(normalized)) return "UTC"; + + try { + new Intl.DateTimeFormat("en-US", { timeZone: normalized }).format(new Date(0)); + return normalized; + } catch { + return null; + } +} + +function dateFromTimeZoneWallClock(input: { + year: number; + month: number; + day: number; + hour: number; + minute: number; + timeZone: string; +}): Date | null { + let candidate = new Date(Date.UTC(input.year, input.month - 1, input.day, input.hour, input.minute, 0, 0)); + const targetUtc = Date.UTC(input.year, input.month - 1, input.day, input.hour, input.minute, 0, 0); + + for (let attempt = 0; attempt < 4; attempt += 1) { + const actual = readTimeZoneParts(candidate, input.timeZone); + const actualUtc = Date.UTC(actual.year, actual.month - 1, actual.day, actual.hour, actual.minute, 0, 0); + const offsetMs = targetUtc - actualUtc; + if (offsetMs === 0) break; + candidate = new Date(candidate.getTime() + offsetMs); + } + + const verified = readTimeZoneParts(candidate, input.timeZone); + if ( + verified.year !== input.year || + verified.month !== input.month || + verified.day !== input.day || + verified.hour !== input.hour || + verified.minute !== input.minute + ) { + return null; + } + + return candidate; +} + +function nextClockTimeInTimeZone(input: { + now: Date; + hour: number; + minute: number; + timeZoneHint: string; +}): Date | null { + const timeZone = normalizeResetTimeZone(input.timeZoneHint); + if (!timeZone) return null; + + const nowParts = readTimeZoneParts(input.now, timeZone); + let retryAt = dateFromTimeZoneWallClock({ + year: nowParts.year, + month: nowParts.month, + day: nowParts.day, + hour: input.hour, + minute: input.minute, + timeZone, + }); + if (!retryAt) return null; + + if (retryAt.getTime() <= input.now.getTime()) { + const nextDay = new Date(Date.UTC(nowParts.year, nowParts.month - 1, nowParts.day + 1, 0, 0, 0, 0)); + retryAt = dateFromTimeZoneWallClock({ + year: nextDay.getUTCFullYear(), + month: nextDay.getUTCMonth() + 1, + day: nextDay.getUTCDate(), + hour: input.hour, + minute: input.minute, + timeZone, + }); + } + + return retryAt; +} + +function parseClaudeResetClockTime(clockText: string, now: Date, timeZoneHint?: string | null): Date | null { const normalized = clockText.trim().replace(/\s+/g, " "); const match = normalized.match(/^(\d{1,2})(?::(\d{2}))?\s*([ap])\.?\s*m\.?/i); if (!match) return null; @@ -219,6 +326,16 @@ function parseClaudeResetClockTime(clockText: string, now: Date): Date | null { let hour24 = hour12 % 12; if ((match[3] ?? "").toLowerCase() === "p") hour24 += 12; + if (timeZoneHint) { + const explicitRetryAt = nextClockTimeInTimeZone({ + now, + hour: hour24, + minute, + timeZoneHint, + }); + if (explicitRetryAt) return explicitRetryAt; + } + const retryAt = new Date(now); retryAt.setHours(hour24, minute, 0, 0); if (retryAt.getTime() <= now.getTime()) { @@ -239,7 +356,7 @@ export function extractClaudeRetryNotBefore( const haystack = buildClaudeTransientHaystack(input); const match = haystack.match(CLAUDE_EXTRA_USAGE_RESET_RE); if (!match) return null; - return parseClaudeResetClockTime(match[1] ?? "", now); + return parseClaudeResetClockTime(match[1] ?? "", now, match[2]); } export function isClaudeTransientUpstreamError(input: { diff --git a/packages/adapters/codex-local/src/server/parse.test.ts b/packages/adapters/codex-local/src/server/parse.test.ts index 3929d5cb73..93b76b1865 100644 --- a/packages/adapters/codex-local/src/server/parse.test.ts +++ b/packages/adapters/codex-local/src/server/parse.test.ts @@ -112,6 +112,15 @@ describe("isCodexTransientUpstreamError", () => { ); }); + it("parses explicit timezone hints on usage-limit retry windows", () => { + const errorMessage = "You've hit your usage limit for GPT-5.3-Codex-Spark. Switch to another model now, or try again at 11:31 PM (America/Chicago)."; + const now = new Date("2026-04-23T03:29:02.000Z"); + + expect(extractCodexRetryNotBefore({ errorMessage }, now)?.toISOString()).toBe( + "2026-04-23T04:31:00.000Z", + ); + }); + it("does not classify deterministic compaction errors as transient", () => { expect( isCodexTransientUpstreamError({ diff --git a/packages/adapters/codex-local/src/server/parse.ts b/packages/adapters/codex-local/src/server/parse.ts index 0312f0d096..679a3f8f4d 100644 --- a/packages/adapters/codex-local/src/server/parse.ts +++ b/packages/adapters/codex-local/src/server/parse.ts @@ -1,4 +1,9 @@ -import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils"; +import { + asString, + asNumber, + parseObject, + parseJson, +} from "@paperclipai/adapter-utils/server-utils"; const CODEX_TRANSIENT_UPSTREAM_RE = /(?:we(?:'|’)re\s+currently\s+experiencing\s+high\s+demand|temporary\s+errors|rate[-\s]?limit(?:ed)?|too\s+many\s+requests|\b429\b|server\s+overloaded|service\s+unavailable|try\s+again\s+later)/i; @@ -95,9 +100,111 @@ function buildCodexErrorHaystack(input: { .join("\n"); } +function readTimeZoneParts(date: Date, timeZone: string) { + const values = new Map( + new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).formatToParts(date).map((part) => [part.type, part.value]), + ); + return { + year: Number.parseInt(values.get("year") ?? "", 10), + month: Number.parseInt(values.get("month") ?? "", 10), + day: Number.parseInt(values.get("day") ?? "", 10), + hour: Number.parseInt(values.get("hour") ?? "", 10), + minute: Number.parseInt(values.get("minute") ?? "", 10), + }; +} + +function normalizeResetTimeZone(timeZoneHint: string | null | undefined): string | null { + const normalized = timeZoneHint?.trim(); + if (!normalized) return null; + if (/^(?:utc|gmt)$/i.test(normalized)) return "UTC"; + + try { + new Intl.DateTimeFormat("en-US", { timeZone: normalized }).format(new Date(0)); + return normalized; + } catch { + return null; + } +} + +function dateFromTimeZoneWallClock(input: { + year: number; + month: number; + day: number; + hour: number; + minute: number; + timeZone: string; +}): Date | null { + let candidate = new Date(Date.UTC(input.year, input.month - 1, input.day, input.hour, input.minute, 0, 0)); + const targetUtc = Date.UTC(input.year, input.month - 1, input.day, input.hour, input.minute, 0, 0); + + for (let attempt = 0; attempt < 4; attempt += 1) { + const actual = readTimeZoneParts(candidate, input.timeZone); + const actualUtc = Date.UTC(actual.year, actual.month - 1, actual.day, actual.hour, actual.minute, 0, 0); + const offsetMs = targetUtc - actualUtc; + if (offsetMs === 0) break; + candidate = new Date(candidate.getTime() + offsetMs); + } + + const verified = readTimeZoneParts(candidate, input.timeZone); + if ( + verified.year !== input.year || + verified.month !== input.month || + verified.day !== input.day || + verified.hour !== input.hour || + verified.minute !== input.minute + ) { + return null; + } + + return candidate; +} + +function nextClockTimeInTimeZone(input: { + now: Date; + hour: number; + minute: number; + timeZoneHint: string; +}): Date | null { + const timeZone = normalizeResetTimeZone(input.timeZoneHint); + if (!timeZone) return null; + + const nowParts = readTimeZoneParts(input.now, timeZone); + let retryAt = dateFromTimeZoneWallClock({ + year: nowParts.year, + month: nowParts.month, + day: nowParts.day, + hour: input.hour, + minute: input.minute, + timeZone, + }); + if (!retryAt) return null; + + if (retryAt.getTime() <= input.now.getTime()) { + const nextDay = new Date(Date.UTC(nowParts.year, nowParts.month - 1, nowParts.day + 1, 0, 0, 0, 0)); + retryAt = dateFromTimeZoneWallClock({ + year: nextDay.getUTCFullYear(), + month: nextDay.getUTCMonth() + 1, + day: nextDay.getUTCDate(), + hour: input.hour, + minute: input.minute, + timeZone, + }); + } + + return retryAt; +} + function parseLocalClockTime(clockText: string, now: Date): Date | null { const normalized = clockText.trim(); - const match = normalized.match(/^(\d{1,2})(?::(\d{2}))?\s*([ap])\.?\s*m\.?(?:\s+[A-Z]{2,5})?$/i); + const match = normalized.match(/^(\d{1,2})(?::(\d{2}))?\s*([ap])\.?\s*m\.?(?:\s*\(([^)]+)\)|\s+([A-Z]{2,5}))?$/i); if (!match) return null; const hour12 = Number.parseInt(match[1] ?? "", 10); @@ -108,6 +215,17 @@ function parseLocalClockTime(clockText: string, now: Date): Date | null { let hour24 = hour12 % 12; if ((match[3] ?? "").toLowerCase() === "p") hour24 += 12; + const timeZoneHint = match[4] ?? match[5]; + if (timeZoneHint) { + const explicitRetryAt = nextClockTimeInTimeZone({ + now, + hour: hour24, + minute, + timeZoneHint, + }); + if (explicitRetryAt) return explicitRetryAt; + } + const retryAt = new Date(now); retryAt.setHours(hour24, minute, 0, 0); if (retryAt.getTime() <= now.getTime()) { diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index b2317bec78..96f5854b44 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -711,12 +711,11 @@ describe("claude execute", () => { expect(result.exitCode).toBe(1); expect(result.errorCode).toBe("claude_transient_upstream"); expect(result.errorFamily).toBe("transient_upstream"); - const expectedRetryNotBefore = new Date(2026, 3, 22, 16, 0, 0, 0).toISOString(); - expect(result.retryNotBefore).toBe(expectedRetryNotBefore); - expect(result.resultJson?.retryNotBefore).toBe(expectedRetryNotBefore); + expect(result.retryNotBefore).toBe("2026-04-22T21:00:00.000Z"); + expect(result.resultJson?.retryNotBefore).toBe("2026-04-22T21:00:00.000Z"); expect(result.errorMessage ?? "").toContain("extra usage"); expect(new Date(String(result.resultJson?.transientRetryNotBefore)).getTime()).toBe( - new Date(2026, 3, 22, 16, 0, 0, 0).getTime(), + new Date("2026-04-22T21:00:00.000Z").getTime(), ); } finally { vi.useRealTimers(); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index c049942bcc..7fa908e4da 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -6346,6 +6346,8 @@ export function heartbeatService(db: Db) { } const deferredCommentIds = extractWakeCommentIds(deferredContextSeed); const deferredWakeReason = readNonEmptyString(deferredContextSeed.wakeReason); + // Only human/comment-reopen interactions should revive completed issues; + // system follow-ups such as retry or cleanup wakes must not reopen closed work. const shouldReopenDeferredCommentWake = deferredCommentIds.length > 0 && (issue.status === "done" || issue.status === "cancelled") &&