-
Notifications
You must be signed in to change notification settings - Fork 6
fix(cron): auto-disable cron jobs whose target channel is unreachable #211
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 6 commits
b66828f
4decf95
abc106f
e1a33ea
1820940
1fdf52b
a831e9d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -117,16 +117,52 @@ async function maybeHandleLauncherCommand(params: { | |
| } | ||
| async function resolveTextChannel(channelId: string, processorId?: string) { | ||
| const attempts: string[] = []; | ||
| // Collect Discord API error codes across every fetch attempt so the | ||
| // caller can detect permanent-access failures (Unknown Channel / Missing | ||
| // Access / etc.) even though we re-throw a generic wrapper Error below. | ||
| // discord.js surfaces these on `err.code` as numbers; we forward them on | ||
| // the wrapper as `discordErrorCodes` and also expose the first as `code` | ||
| // so `isPermanentChannelError` can pick it up via the `code` shape. | ||
| // | ||
| // The pinned bot's code is tracked separately: when `processorId` is | ||
| // provided we know which bot actually owns the channel, so its result | ||
| // is authoritative and unrelated bots' "permanent" errors (e.g. another | ||
| // workspace's bot legitimately returning `Missing Access` for a channel | ||
| // it cannot see) MUST NOT promote the wrapper to permanent. See PR #211 | ||
| // discussion: "Avoid disabling Discord jobs on mixed fetch failures". | ||
| const discordErrorCodes: number[] = []; | ||
| let pinnedBotErrorCode: number | undefined; | ||
| let pinnedBotAttempted = false; | ||
| // Count every fetch attempt that ended in a thrown error — not just the | ||
| // subset that carried a numeric `err.code`. The wrapper's | ||
| // `isPermanentChannelError` classification must NOT promote to permanent | ||
| // when any attempt failed transiently (e.g. ECONNRESET, generic network | ||
| // error), because that bot might succeed on retry. See PR #211 discussion: | ||
| // "Require every Discord fetch attempt to be permanent". | ||
| let totalFailedAttempts = 0; | ||
| const captureCode = (error: unknown, isPinnedBot: boolean) => { | ||
| totalFailedAttempts += 1; | ||
| if (typeof error === "object" && error !== null) { | ||
| const code = (error as { code?: unknown }).code; | ||
| if (typeof code === "number") { | ||
| discordErrorCodes.push(code); | ||
| if (isPinnedBot) pinnedBotErrorCode = code; | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| if (processorId) { | ||
| const pinnedClient = discordClientByProcessorId.get(processorId); | ||
| if (pinnedClient) { | ||
| pinnedBotAttempted = true; | ||
| try { | ||
| const channel = await pinnedClient.channels.fetch(channelId); | ||
| if (channel && channel.isTextBased()) { | ||
| return channel as any; | ||
| } | ||
| attempts.push(`bot=${pinnedClient.user?.id || "unknown"}: channel_not_text_or_missing`); | ||
| } catch (error) { | ||
| captureCode(error, true); | ||
| const errorMessage = error instanceof Error ? error.message : String(error); | ||
| attempts.push(`bot=${pinnedClient.user?.id || "unknown"}: ${errorMessage}`); | ||
| } | ||
|
|
@@ -142,6 +178,7 @@ async function resolveTextChannel(channelId: string, processorId?: string) { | |
| } | ||
| attempts.push(`bot=${client.user?.id || "unknown"}: channel_not_text_or_missing`); | ||
| } catch (error) { | ||
| captureCode(error, false); | ||
| const errorMessage = error instanceof Error ? error.message : String(error); | ||
| attempts.push(`bot=${client.user?.id || "unknown"}: ${errorMessage}`); | ||
| } | ||
|
|
@@ -155,7 +192,44 @@ async function resolveTextChannel(channelId: string, processorId?: string) { | |
| }); | ||
| } | ||
|
|
||
| throw new Error(`Discord channel ${channelId} is not text-based or inaccessible`); | ||
| const wrapper = new Error(`Discord channel ${channelId} is not text-based or inaccessible`); | ||
| if (discordErrorCodes.length > 0) { | ||
| const PERMANENT_PRIORITY = new Set([10003, 50001, 50013, 50007]); | ||
|
|
||
| // Pick which code to forward as the `code` field consumed by | ||
| // `isPermanentChannelError`. Rules: | ||
| // 1. If a pinned bot was attempted, its outcome is authoritative — | ||
| // forward its code (if any). Other bots' permanent codes for the | ||
| // same channel are unrelated noise. | ||
| // 2. If no pinned bot was attempted (e.g. cron top-level send with | ||
| // no processorId), only treat the failure as permanent when every | ||
| // failed attempt carried a permanent code. A transient failure | ||
| // (ECONNRESET, network blip) without a numeric `code` would not | ||
| // appear in `discordErrorCodes`, so counting only those would let | ||
| // an unrelated bot's permanent code promote the wrapper even though | ||
| // a retry might succeed. We therefore also require that the number | ||
| // of captured permanent codes equals the number of failed attempts. | ||
| let forwardedCode: number | undefined; | ||
| if (pinnedBotAttempted) { | ||
| forwardedCode = pinnedBotErrorCode; | ||
| } else { | ||
| const permanentCodes = discordErrorCodes.filter((c) => PERMANENT_PRIORITY.has(c)); | ||
| const allAttemptsPermanent = | ||
| permanentCodes.length === totalFailedAttempts && totalFailedAttempts > 0; | ||
| if (allAttemptsPermanent) { | ||
| forwardedCode = permanentCodes[0]; | ||
|
Comment on lines
+230
to
+233
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In a multi-workspace daemon, this unpinned path only considers the Discord clients that are currently connected; if the cron job's own workspace client failed to log in or is temporarily restarting, every remaining bot can return permanent-looking Useful? React with 👍 / 👎. |
||
| } else { | ||
| forwardedCode = discordErrorCodes[0]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In an unpinned multi-bot Discord send, if one fetch fails with a permanent code and another fetch fails with any non-permanent numeric DiscordAPIError, Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in a831e9d. The unpinned mixed-failure branch no longer forwards New regression test in
|
||
| } | ||
| } | ||
|
|
||
| if (forwardedCode !== undefined) { | ||
| (wrapper as Error & { code?: number; discordErrorCodes?: number[] }).code = forwardedCode; | ||
| } | ||
| (wrapper as Error & { code?: number; discordErrorCodes?: number[] }).discordErrorCodes = | ||
| [...discordErrorCodes]; | ||
| } | ||
| throw wrapper; | ||
| } | ||
|
|
||
| async function buildDiscordContext( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a Lark cron job's channel/workspace credentials have been removed,
sendLarkChannelMessagereturnsundefinedinstead of throwing (packages/ims/lark/client.ts:383-384), so this await falls through the new permanent-error handling and the run is later marked completed while the job remains enabled and undelivered. Fresh evidence after the stale-channel fix is that the direct-SQL disable helper is never reached for this no-credentials Lark path because no error is raised.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in 1fdf52b.
sendResultToChannelnow checks the Lark return value and throwsLark send returned no message id for channel <id> (chat_not_found or credentials missing)whensendLarkChannelMessagereturnsundefined(the missing-credentials path atpackages/ims/lark/client.ts:383-384). The wrapper text embedschat_not_found, so the existingisPermanentChannelErrorcheck in therunCronJobcatch branch classifies it as permanent anddisableCronJobForPermanentChannelErrordisables the job — same terminal behavior as an explicitchat_not_foundAPI response, no more silent completed-but-undelivered runs.Also added a test in
permanent-error.test.ts(matches the synthetic 'Lark send returned no message id' wrapper) that pins the exact wrapper string to the classifier, so a future rewording of the wrapper cannot silently regress the auto-disable path. Full suite: 437 pass, 1 skip, 0 fail.