@open-mercato/channel-discord 0.7.1-develop.7202.1.a52dc6707c → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -91,6 +91,7 @@ async function quarantineDiscordChannel(params) {
91
91
  if (!channel) return "not_found";
92
92
  channel.status = "requires_reauth";
93
93
  channel.lastError = params.reason;
94
+ channel.isActive = false;
94
95
  await fork.flush();
95
96
  return "quarantined";
96
97
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/channel_discord/lib/channel-state-store.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'\nimport { discordChannelStateSchema, type DiscordChannelState } from './credentials'\n\n/**\n * Tenant/organization scope every Discord channel-state write MUST carry.\n * The gateway worker persists resume state from a WebSocket callback that fires\n * long after the job handler returned, so the scope is captured up front from\n * the channel row the job loaded and replayed on every write.\n */\nexport type DiscordChannelScope = {\n tenantId: string\n organizationId: string | null\n}\n\n/** The gateway-owned subset of `channelState` this store is allowed to write. */\nexport type DiscordChannelStatePatch = {\n sessionId?: string\n sequence?: number | null\n resumeGatewayUrl?: string\n botUserId?: string\n}\n\nexport type PersistDiscordChannelStateResult = 'written' | 'skipped' | 'not_found'\n\n/**\n * Merge a gateway resume-state patch onto the state currently stored on the\n * channel row, or return `null` when the patch must not be written.\n *\n * Two rules make this safe against the socket callbacks racing each other:\n *\n * - Only the gateway-owned keys are touched; every other key (`aiAutoReplyEnabled`,\n * `aiAgentId`, operator-set fields) is carried forward from the row that was\n * just read, so a concurrent UI edit is never clobbered wholesale.\n * - A patch that would rewind the sequence of the session already stored is\n * dropped. A late `READY` callback from a superseded socket must not overwrite\n * fresher resume state with a stale cursor.\n */\nexport function mergeDiscordChannelState(\n currentRaw: unknown,\n patch: DiscordChannelStatePatch,\n now: Date,\n): DiscordChannelState | null {\n const current = discordChannelStateSchema.parse(currentRaw ?? {})\n const sameSession = Boolean(patch.sessionId) && patch.sessionId === current.sessionId\n const rewindsSequence =\n sameSession &&\n typeof patch.sequence === 'number' &&\n typeof current.sequence === 'number' &&\n patch.sequence < current.sequence\n if (rewindsSequence) return null\n\n return {\n ...current,\n ...patch,\n lastConnectedAt: now.toISOString(),\n }\n}\n\n/**\n * Scoped read-modify-write of a Discord channel's gateway resume state.\n *\n * The lookup filters by `tenant_id` / `organization_id` (never by id alone) so a\n * socket callback can only ever touch the row its own channel scope owns, and it\n * runs on its own `EntityManager` fork so the flush never rides on the request\n * context the worker job was started from.\n */\nexport async function persistDiscordChannelState(params: {\n em: EntityManager\n channelId: string\n scope: DiscordChannelScope\n patch: DiscordChannelStatePatch\n now?: Date\n}): Promise<PersistDiscordChannelStateResult> {\n const fork = params.em.fork()\n const channel = await findOneWithDecryption(\n fork,\n CommunicationChannel,\n {\n id: params.channelId,\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId,\n deletedAt: null,\n },\n undefined,\n {\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId ?? params.scope.tenantId,\n },\n )\n if (!channel) return 'not_found'\n\n const merged = mergeDiscordChannelState(channel.channelState, params.patch, params.now ?? new Date())\n if (!merged) return 'skipped'\n\n channel.channelState = merged\n await fork.flush()\n return 'written'\n}\n\nexport type RecordAutoReplyOutcomeResult = 'written' | 'unchanged' | 'not_found'\n\n/** Truncated so a provider stack trace cannot grow the JSONB column without bound. */\nconst AUTO_REPLY_ERROR_MAX_LENGTH = 500\n\n/**\n * Record \u2014 or clear \u2014 why the last AI auto-reply attempt on this channel produced\n * nothing.\n *\n * The subscriber degrades every failure to a no-op on purpose: a broken model, a\n * denied policy check or a malformed object must never turn into a send. Being\n * *silent* about it is the part that hurts, because the operator's surface still\n * reads \"Auto-reply on\" while the channel answers nothing, and a `logger.warn` in\n * a background subscriber is not somewhere anyone looks. A save-time check cannot\n * close this on its own either \u2014 the settings route validates the agent against\n * the auto-reply principal, but a role edited afterwards makes that verdict stale.\n *\n * `failure: null` clears the marker, and the caller is expected to skip the write\n * when there is nothing to clear \u2014 a successful reply on a healthy channel must\n * not cost a row update per inbound message.\n *\n * The channel row's own `lastError` is deliberately left alone: the hub owns it\n * for delivery and polling failures, clears it on a successful poll, and keys the\n * reauth banner off `status`. Borrowing it here would let an AI failure mask a\n * delivery failure and let a successful poll erase an AI one.\n */\nexport async function recordDiscordAutoReplyOutcome(params: {\n em: EntityManager\n channelId: string\n scope: DiscordChannelScope\n failure: string | null\n now?: Date\n}): Promise<RecordAutoReplyOutcomeResult> {\n const fork = params.em.fork()\n const channel = await findOneWithDecryption(\n fork,\n CommunicationChannel,\n {\n id: params.channelId,\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId,\n deletedAt: null,\n },\n undefined,\n {\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId ?? params.scope.tenantId,\n },\n )\n if (!channel) return 'not_found'\n\n const current = discordChannelStateSchema.parse(channel.channelState ?? {})\n const failure = params.failure?.slice(0, AUTO_REPLY_ERROR_MAX_LENGTH) ?? null\n if (failure === null && current.aiAutoReplyLastError === undefined) return 'unchanged'\n if (failure !== null && current.aiAutoReplyLastError === failure) return 'unchanged'\n\n const next: DiscordChannelState = { ...current }\n if (failure === null) {\n delete next.aiAutoReplyLastError\n delete next.aiAutoReplyLastErrorAt\n } else {\n next.aiAutoReplyLastError = failure\n next.aiAutoReplyLastErrorAt = (params.now ?? new Date()).toISOString()\n }\n\n channel.channelState = next\n await fork.flush()\n return 'written'\n}\n\nexport type QuarantineDiscordChannelResult = 'quarantined' | 'not_found'\n\n/**\n * Park a channel Discord has fatally rejected so the reconciler stops re-opening it.\n *\n * A `4004` (invalid token) or `4014` (disallowed intents) close is not\n * recoverable by retrying: the token or the Developer Portal toggle has to\n * change first. Without a persisted status the refresh job sees \"no live\n * session\" on the next tick and IDENTIFYs again \u2014 at the CLI's default 60s\n * refresh that is ~1440 session starts per day against Discord's ~1000/day\n * per-bot budget, while the row and the admin UI still read `connected`.\n *\n * Writing `requires_reauth` mirrors what the hub already does for a channel\n * whose credentials could not be persisted (`connect-channel.ts`), so the\n * existing reauth banner, the mutation guard that keys on `status`, and the\n * operator's reconnect flow all engage without any hub change.\n */\nexport async function quarantineDiscordChannel(params: {\n em: EntityManager\n channelId: string\n scope: DiscordChannelScope\n reason: string\n}): Promise<QuarantineDiscordChannelResult> {\n const fork = params.em.fork()\n const channel = await findOneWithDecryption(\n fork,\n CommunicationChannel,\n {\n id: params.channelId,\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId,\n deletedAt: null,\n },\n undefined,\n {\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId ?? params.scope.tenantId,\n },\n )\n if (!channel) return 'not_found'\n\n channel.status = 'requires_reauth'\n channel.lastError = params.reason\n await fork.flush()\n return 'quarantined'\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '@open-mercato/core/modules/communication_channels/data/entities'\nimport { discordChannelStateSchema, type DiscordChannelState } from './credentials'\n\n/**\n * Tenant/organization scope every Discord channel-state write MUST carry.\n * The gateway worker persists resume state from a WebSocket callback that fires\n * long after the job handler returned, so the scope is captured up front from\n * the channel row the job loaded and replayed on every write.\n */\nexport type DiscordChannelScope = {\n tenantId: string\n organizationId: string | null\n}\n\n/** The gateway-owned subset of `channelState` this store is allowed to write. */\nexport type DiscordChannelStatePatch = {\n sessionId?: string\n sequence?: number | null\n resumeGatewayUrl?: string\n botUserId?: string\n}\n\nexport type PersistDiscordChannelStateResult = 'written' | 'skipped' | 'not_found'\n\n/**\n * Merge a gateway resume-state patch onto the state currently stored on the\n * channel row, or return `null` when the patch must not be written.\n *\n * Two rules make this safe against the socket callbacks racing each other:\n *\n * - Only the gateway-owned keys are touched; every other key (`aiAutoReplyEnabled`,\n * `aiAgentId`, operator-set fields) is carried forward from the row that was\n * just read, so a concurrent UI edit is never clobbered wholesale.\n * - A patch that would rewind the sequence of the session already stored is\n * dropped. A late `READY` callback from a superseded socket must not overwrite\n * fresher resume state with a stale cursor.\n */\nexport function mergeDiscordChannelState(\n currentRaw: unknown,\n patch: DiscordChannelStatePatch,\n now: Date,\n): DiscordChannelState | null {\n const current = discordChannelStateSchema.parse(currentRaw ?? {})\n const sameSession = Boolean(patch.sessionId) && patch.sessionId === current.sessionId\n const rewindsSequence =\n sameSession &&\n typeof patch.sequence === 'number' &&\n typeof current.sequence === 'number' &&\n patch.sequence < current.sequence\n if (rewindsSequence) return null\n\n return {\n ...current,\n ...patch,\n lastConnectedAt: now.toISOString(),\n }\n}\n\n/**\n * Scoped read-modify-write of a Discord channel's gateway resume state.\n *\n * The lookup filters by `tenant_id` / `organization_id` (never by id alone) so a\n * socket callback can only ever touch the row its own channel scope owns, and it\n * runs on its own `EntityManager` fork so the flush never rides on the request\n * context the worker job was started from.\n */\nexport async function persistDiscordChannelState(params: {\n em: EntityManager\n channelId: string\n scope: DiscordChannelScope\n patch: DiscordChannelStatePatch\n now?: Date\n}): Promise<PersistDiscordChannelStateResult> {\n const fork = params.em.fork()\n const channel = await findOneWithDecryption(\n fork,\n CommunicationChannel,\n {\n id: params.channelId,\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId,\n deletedAt: null,\n },\n undefined,\n {\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId ?? params.scope.tenantId,\n },\n )\n if (!channel) return 'not_found'\n\n const merged = mergeDiscordChannelState(channel.channelState, params.patch, params.now ?? new Date())\n if (!merged) return 'skipped'\n\n channel.channelState = merged\n await fork.flush()\n return 'written'\n}\n\nexport type RecordAutoReplyOutcomeResult = 'written' | 'unchanged' | 'not_found'\n\n/** Truncated so a provider stack trace cannot grow the JSONB column without bound. */\nconst AUTO_REPLY_ERROR_MAX_LENGTH = 500\n\n/**\n * Record \u2014 or clear \u2014 why the last AI auto-reply attempt on this channel produced\n * nothing.\n *\n * The subscriber degrades every failure to a no-op on purpose: a broken model, a\n * denied policy check or a malformed object must never turn into a send. Being\n * *silent* about it is the part that hurts, because the operator's surface still\n * reads \"Auto-reply on\" while the channel answers nothing, and a `logger.warn` in\n * a background subscriber is not somewhere anyone looks. A save-time check cannot\n * close this on its own either \u2014 the settings route validates the agent against\n * the auto-reply principal, but a role edited afterwards makes that verdict stale.\n *\n * `failure: null` clears the marker, and the caller is expected to skip the write\n * when there is nothing to clear \u2014 a successful reply on a healthy channel must\n * not cost a row update per inbound message.\n *\n * The channel row's own `lastError` is deliberately left alone: the hub owns it\n * for delivery and polling failures, clears it on a successful poll, and keys the\n * reauth banner off `status`. Borrowing it here would let an AI failure mask a\n * delivery failure and let a successful poll erase an AI one.\n */\nexport async function recordDiscordAutoReplyOutcome(params: {\n em: EntityManager\n channelId: string\n scope: DiscordChannelScope\n failure: string | null\n now?: Date\n}): Promise<RecordAutoReplyOutcomeResult> {\n const fork = params.em.fork()\n const channel = await findOneWithDecryption(\n fork,\n CommunicationChannel,\n {\n id: params.channelId,\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId,\n deletedAt: null,\n },\n undefined,\n {\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId ?? params.scope.tenantId,\n },\n )\n if (!channel) return 'not_found'\n\n const current = discordChannelStateSchema.parse(channel.channelState ?? {})\n const failure = params.failure?.slice(0, AUTO_REPLY_ERROR_MAX_LENGTH) ?? null\n if (failure === null && current.aiAutoReplyLastError === undefined) return 'unchanged'\n if (failure !== null && current.aiAutoReplyLastError === failure) return 'unchanged'\n\n const next: DiscordChannelState = { ...current }\n if (failure === null) {\n delete next.aiAutoReplyLastError\n delete next.aiAutoReplyLastErrorAt\n } else {\n next.aiAutoReplyLastError = failure\n next.aiAutoReplyLastErrorAt = (params.now ?? new Date()).toISOString()\n }\n\n channel.channelState = next\n await fork.flush()\n return 'written'\n}\n\nexport type QuarantineDiscordChannelResult = 'quarantined' | 'not_found'\n\n/**\n * Park a channel Discord has fatally rejected so the reconciler stops re-opening it.\n *\n * A `4004` (invalid token) or `4014` (disallowed intents) close is not\n * recoverable by retrying: the token or the Developer Portal toggle has to\n * change first. Without a persisted status the refresh job sees \"no live\n * session\" on the next tick and IDENTIFYs again \u2014 at the CLI's default 60s\n * refresh that is ~1440 session starts per day against Discord's ~1000/day\n * per-bot budget, while the row and the admin UI still read `connected`.\n *\n * Writing `requires_reauth` + `isActive = false` mirrors what the hub already\n * does for a channel whose credentials could not be persisted\n * (`connect-channel.ts`'s `applyConnectionState`), so the existing reauth\n * banner, the mutation guard that keys on `status`, the admin channel list\n * (keyed on `isActive`), and the operator's reconnect flow all engage without\n * any hub change.\n */\nexport async function quarantineDiscordChannel(params: {\n em: EntityManager\n channelId: string\n scope: DiscordChannelScope\n reason: string\n}): Promise<QuarantineDiscordChannelResult> {\n const fork = params.em.fork()\n const channel = await findOneWithDecryption(\n fork,\n CommunicationChannel,\n {\n id: params.channelId,\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId,\n deletedAt: null,\n },\n undefined,\n {\n tenantId: params.scope.tenantId,\n organizationId: params.scope.organizationId ?? params.scope.tenantId,\n },\n )\n if (!channel) return 'not_found'\n\n channel.status = 'requires_reauth'\n channel.lastError = params.reason\n // Mirrors `connect-channel.ts`'s `applyConnectionState`: the admin list and\n // the reconciler's `isActive: true` query filter both key on this flag, not\n // `status` \u2014 without it the channel keeps reporting Active and the next\n // refresh tick's query would still pick it up.\n channel.isActive = false\n await fork.flush()\n return 'quarantined'\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/channel-discord",
3
- "version": "0.7.1-develop.7202.1.a52dc6707c",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -62,14 +62,14 @@
62
62
  }
63
63
  },
64
64
  "dependencies": {
65
- "@open-mercato/core": "0.7.1-develop.7202.1.a52dc6707c",
66
- "@open-mercato/queue": "0.7.1-develop.7202.1.a52dc6707c",
67
- "@open-mercato/ui": "0.7.1-develop.7202.1.a52dc6707c"
65
+ "@open-mercato/core": "0.8.0",
66
+ "@open-mercato/queue": "0.8.0",
67
+ "@open-mercato/ui": "0.8.0"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@mikro-orm/postgresql": "^7.0.14",
71
- "@open-mercato/ai-assistant": "0.7.1-develop.7202.1.a52dc6707c",
72
- "@open-mercato/shared": "0.7.1-develop.7202.1.a52dc6707c",
71
+ "@open-mercato/ai-assistant": "0.8.0",
72
+ "@open-mercato/shared": "0.8.0",
73
73
  "react": "^19.0.0",
74
74
  "react-dom": "^19.0.0"
75
75
  },
@@ -79,8 +79,8 @@
79
79
  }
80
80
  },
81
81
  "devDependencies": {
82
- "@open-mercato/ai-assistant": "0.7.1-develop.7202.1.a52dc6707c",
83
- "@open-mercato/shared": "0.7.1-develop.7202.1.a52dc6707c",
82
+ "@open-mercato/ai-assistant": "0.8.0",
83
+ "@open-mercato/shared": "0.8.0",
84
84
  "@types/jest": "^30.0.0",
85
85
  "@types/react": "^19.2.18",
86
86
  "@types/react-dom": "^19.2.5",
@@ -99,6 +99,5 @@
99
99
  "type": "git",
100
100
  "url": "https://github.com/open-mercato/open-mercato",
101
101
  "directory": "packages/channel-discord"
102
- },
103
- "stableVersion": "0.7.0"
102
+ }
104
103
  }
@@ -1,4 +1,5 @@
1
1
  import { quarantineDiscordChannel } from '../channel-state-store'
2
+ import { buildGatewayChannelFilter } from '../../workers/discord-gateway'
2
3
 
3
4
  jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
4
5
  findOneWithDecryption: jest.fn(),
@@ -21,7 +22,7 @@ const { findOneWithDecryption } = require('@open-mercato/shared/lib/encryption/f
21
22
  * `is_active=t, status=connected, last_error=NULL`; at the default `--refresh 60`
22
23
  * that is ~1440 session starts per day against Discord's ~1000/day per-bot budget.
23
24
  */
24
- type FakeChannel = { status: string; lastError: string | null }
25
+ type FakeChannel = { status: string; lastError: string | null; isActive: boolean }
25
26
 
26
27
  function fakeEm(channel: FakeChannel | null): { em: unknown; flush: jest.Mock } {
27
28
  const flush = jest.fn().mockResolvedValue(undefined)
@@ -38,7 +39,7 @@ describe('quarantineDiscordChannel', () => {
38
39
  })
39
40
 
40
41
  it('parks the channel as requires_reauth with the close code as the reason', async () => {
41
- const channel: FakeChannel = { status: 'connected', lastError: null }
42
+ const channel: FakeChannel = { status: 'connected', lastError: null, isActive: true }
42
43
  const { em, flush } = fakeEm(channel)
43
44
 
44
45
  const result = await quarantineDiscordChannel({
@@ -54,11 +55,55 @@ describe('quarantineDiscordChannel', () => {
54
55
  expect(flush).toHaveBeenCalledTimes(1)
55
56
  })
56
57
 
58
+ it('flips isActive to false so the admin list stops reporting the channel as Active', async () => {
59
+ // Regression: the reconciler already skipped `status === 'requires_reauth'`,
60
+ // but the admin channels list (`backend/communication_channels/channels/page.tsx`)
61
+ // renders its Active/Inactive badge from `isActive`, not `status` — so a
62
+ // quarantined channel kept showing Active until this flipped too.
63
+ const channel: FakeChannel = { status: 'connected', lastError: null, isActive: true }
64
+ const { em } = fakeEm(channel)
65
+
66
+ await quarantineDiscordChannel({
67
+ em: em as never,
68
+ channelId: 'chan-1',
69
+ scope: SCOPE,
70
+ reason: 'gateway_close_4004',
71
+ })
72
+
73
+ expect(channel.isActive).toBe(false)
74
+ })
75
+
76
+ it('makes the quarantined row drop out of the reconciler’s next-tick query filter', async () => {
77
+ // `buildGatewayChannelFilter` is what the reconciler runs on every refresh
78
+ // tick to decide which channels to (re)connect. It filters on `isActive:
79
+ // true`, so once quarantine flips that flag, the same channel row that
80
+ // caused 16 fatal closes over 10 ticks (see #4979) no longer matches on
81
+ // the next tick — no persisted `status` lookup is even needed to skip it.
82
+ const channel: FakeChannel & { providerKey: string; deletedAt: null } = {
83
+ status: 'connected',
84
+ lastError: null,
85
+ isActive: true,
86
+ providerKey: 'discord',
87
+ deletedAt: null,
88
+ }
89
+ const { em } = fakeEm(channel)
90
+
91
+ await quarantineDiscordChannel({
92
+ em: em as never,
93
+ channelId: 'chan-1',
94
+ scope: SCOPE,
95
+ reason: 'gateway_close_4014',
96
+ })
97
+
98
+ const nextTickFilter = buildGatewayChannelFilter({})
99
+ expect(channel.isActive).not.toBe(nextTickFilter.isActive)
100
+ })
101
+
57
102
  it('looks the channel up inside its own tenant scope, never by id alone', async () => {
58
- fakeEm({ status: 'connected', lastError: null })
103
+ fakeEm({ status: 'connected', lastError: null, isActive: true })
59
104
 
60
105
  await quarantineDiscordChannel({
61
- em: (fakeEm({ status: 'connected', lastError: null }).em) as never,
106
+ em: (fakeEm({ status: 'connected', lastError: null, isActive: true }).em) as never,
62
107
  channelId: 'chan-1',
63
108
  scope: SCOPE,
64
109
  reason: 'gateway_close_4004',
@@ -181,10 +181,12 @@ export type QuarantineDiscordChannelResult = 'quarantined' | 'not_found'
181
181
  * refresh that is ~1440 session starts per day against Discord's ~1000/day
182
182
  * per-bot budget, while the row and the admin UI still read `connected`.
183
183
  *
184
- * Writing `requires_reauth` mirrors what the hub already does for a channel
185
- * whose credentials could not be persisted (`connect-channel.ts`), so the
186
- * existing reauth banner, the mutation guard that keys on `status`, and the
187
- * operator's reconnect flow all engage without any hub change.
184
+ * Writing `requires_reauth` + `isActive = false` mirrors what the hub already
185
+ * does for a channel whose credentials could not be persisted
186
+ * (`connect-channel.ts`'s `applyConnectionState`), so the existing reauth
187
+ * banner, the mutation guard that keys on `status`, the admin channel list
188
+ * (keyed on `isActive`), and the operator's reconnect flow all engage without
189
+ * any hub change.
188
190
  */
189
191
  export async function quarantineDiscordChannel(params: {
190
192
  em: EntityManager
@@ -212,6 +214,11 @@ export async function quarantineDiscordChannel(params: {
212
214
 
213
215
  channel.status = 'requires_reauth'
214
216
  channel.lastError = params.reason
217
+ // Mirrors `connect-channel.ts`'s `applyConnectionState`: the admin list and
218
+ // the reconciler's `isActive: true` query filter both key on this flag, not
219
+ // `status` — without it the channel keeps reporting Active and the next
220
+ // refresh tick's query would still pick it up.
221
+ channel.isActive = false
215
222
  await fork.flush()
216
223
  return 'quarantined'
217
224
  }