Skip to content

fix(discord): persist hello-stall counter and add escalation across health-monitor restarts#40413

Closed
jellyAI-dev wants to merge 8 commits into
openclaw:mainfrom
jellyAI-dev:fix/discord-health-restart-loop
Closed

fix(discord): persist hello-stall counter and add escalation across health-monitor restarts#40413
jellyAI-dev wants to merge 8 commits into
openclaw:mainfrom
jellyAI-dev:fix/discord-health-restart-loop

Conversation

@jellyAI-dev

Copy link
Copy Markdown

Problem

Fixes #38596.

After a Discord WebSocket disconnect, the health monitor restarts the Discord provider lifecycle. But each new lifecycle resets consecutiveHelloStalls (local variable) to zero, so circuit breakers never accumulate across restarts. The result: connect → zombie → health-monitor restart → connect → zombie → indefinite loop.

Changes

1. src/discord/monitor/provider.lifecycle.ts — Persistent state across restarts

  • Added DiscordLifecyclePersistentState type with consecutiveHelloStalls and forceCleanRestart fields
  • Module-level Map<accountId, State> persists across lifecycle invocations (survives health-monitor restarts)
  • Exported getOrCreatePersistentState(), requestCleanRestart(), markStable() for external wiring
  • On lifecycle start, if forceCleanRestart is set, clears Discord resume state for a fresh IDENTIFY

2. src/gateway/channel-health-monitor.ts — Consecutive restart tracking + escalation + backoff

  • Added consecutiveRestarts counter to RestartRecord
  • Stability detection: channel staying healthy >5 min resets counter and calls onChannelStable callback
  • Escalation: after 3 consecutive restarts without stability, calls onBeforeRestart callback so callers can request a clean restart
  • Exponential backoff: cooldown = base x 2^min(consecutive, 3), capped at 60 min (10m -> 20m -> 40m -> 60m)

3. src/gateway/server.impl.ts — Wiring

  • onBeforeRestart: for discord channels, calls requestDiscordCleanRestart(accountId)
  • onChannelStable: for discord channels, calls markDiscordStable(accountId) to reset persistent counters

4. Tests — 12 new tests

  • Persistent state utilities
  • consecutiveHelloStalls persists across lifecycle restarts and triggers fresh IDENTIFY at threshold
  • forceCleanRestart clears resume state on lifecycle start
  • Health monitor consecutive restart tracking with onBeforeRestart escalation
  • Stability reset via onChannelStable
  • Exponential backoff timing verification

Observed in production

Gateway logs showing exact pattern from the issue report — Discord logs in successfully after each restart but goes zombie within 5-10 min, health monitor retries indefinitely, gateway eventually dies.

@openclaw-barnacle openclaw-barnacle Bot added channel: discord Channel integration: discord gateway Gateway runtime size: M labels Mar 9, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 777b31dc91

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +172 to +175
record.consecutiveRestarts > 0 &&
record.lastRestartAt > 0 &&
now - record.lastRestartAt >= STABLE_THRESHOLD_MS
) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track healthy duration before resetting restart streak

The stability reset currently keys off now - record.lastRestartAt >= STABLE_THRESHOLD_MS, which means a channel can be unhealthy for a long time, become healthy for a single check, and immediately zero consecutiveRestarts. In that flapping scenario, the restart streak is cleared without actually being stable for 5 minutes, so escalation (onBeforeRestart) can be repeatedly delayed or skipped and Discord clean-restart requests may never trigger as intended.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 68fd7d6 — added healthySince?: number to RestartRecord.

The stability window now measures continuous uptime from the first healthy observation after a restart, not elapsed time since the last restart. healthySince is set on the first healthy check after a restart and cleared immediately when the channel goes unhealthy — so a brief healthy→unhealthy flap cannot satisfy the 5-minute window and prematurely zero consecutiveRestarts.

New test: "does not reset consecutive restart counter on brief healthy→unhealthy flap" covers this exact scenario. 32/32 passing.

@greptile-apps

greptile-apps Bot commented Mar 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a long-standing infinite-restart loop in the Discord gateway by making consecutiveHelloStalls persistent across health-monitor restarts and adding exponential backoff + escalation to the channel health monitor. The approach is sound and the 12 new tests give good coverage of both the happy path and boundary conditions.

Key changes:

  • provider.lifecycle.ts – module-level Map keyed by accountId persists consecutiveHelloStalls and a forceCleanRestart flag across lifecycle invocations; a forceCleanRestart flag triggers clearResumeState() on the next lifecycle start so the gateway performs a fresh IDENTIFY instead of a broken RESUME.
  • channel-health-monitor.tsRestartRecord gains a consecutiveRestarts counter; cooldown becomes exponential (base × 2^min(n,3)); stability detection resets the counter after 5 minutes of healthy uptime; a new onBeforeRestart callback fires after ESCALATION_THRESHOLD (3) consecutive restarts so callers can take channel-specific recovery actions.
  • server.impl.ts – wires the two new callbacks for Discord channels only, connecting the health monitor's escalation signal to the lifecycle's requestCleanRestart / markStable helpers.

Issues found:

  • The onBeforeRestart callback is invoked before the try block that actually performs the restart. If the restart fails, the record is not updated, causing the callback to fire again on the next check cycle when the cooldown expires. While this is currently harmless due to idempotency (setting a flag to true), it violates the contract and could cause issues if the callback gains side-effects.
  • The module-level persistentStates Map is never pruned. Entries for deleted or disabled Discord accounts accumulate indefinitely, creating a slow memory leak in long-running gateway processes.

Confidence Score: 4/5

  • Safe to merge with minor cleanup recommended before the callback contract hardens.
  • The core fix is correct and well-tested. The onBeforeRestart-before-try ordering is a real contract violation but is harmless in the current codebase due to idempotency. The unbounded persistentStates Map is a long-term maintenance concern rather than an immediate bug. Neither issue blocks correctness of the primary fix.
  • src/gateway/channel-health-monitor.ts (escalation callback placement) and src/discord/monitor/provider.lifecycle.ts (Map cleanup).

Last reviewed commit: 777b31d

Comment on lines +212 to +218
if (nextConsecutive >= ESCALATION_THRESHOLD && onBeforeRestart) {
onBeforeRestart({
channelId: channelId as ChannelId,
accountId,
consecutiveRestarts: nextConsecutive,
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onBeforeRestart fires before confirming restart succeeded. The callback is invoked at lines 212-218, before the try block. If stopChannel or startChannel throws, record.consecutiveRestarts is not updated (stays at, e.g., 2). On the next check interval, once the cooldown expires, the same threshold is reached and onBeforeRestart fires again — even though no lifecycle was ever actually started.

The immediate practical consequence is that forceCleanRestart is re-set to true on every failed retry cycle at or above the threshold, rather than exactly once per restart decision. As long as the callback stays idempotent this won't cause a functional breakage, but it violates the documented contract and could be surprising if the callback ever gains additional side-effects.

Suggested fix: Move the escalation call inside the try block (right before startChannel), or update the record on failure:

try {
  if (status.running) {
    await channelManager.stopChannel(channelId as ChannelId, accountId);
  }
  channelManager.resetRestartAttempts(channelId as ChannelId, accountId);

  if (nextConsecutive >= ESCALATION_THRESHOLD && onBeforeRestart) {
    onBeforeRestart({ channelId: channelId as ChannelId, accountId, consecutiveRestarts: nextConsecutive });
  }

  await channelManager.startChannel(channelId as ChannelId, accountId);
  record.lastRestartAt = now;
  record.restartsThisHour.push({ at: now });
  record.consecutiveRestarts = nextConsecutive;
  restartRecords.set(key, record);
} catch (err) {  }
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/channel-health-monitor.ts
Line: 212-218

Comment:
`onBeforeRestart` fires before confirming restart succeeded. The callback is invoked at lines 212-218, *before* the try block. If `stopChannel` or `startChannel` throws, `record.consecutiveRestarts` is not updated (stays at, e.g., `2`). On the next check interval, once the cooldown expires, the same threshold is reached and `onBeforeRestart` fires again — even though no lifecycle was ever actually started.

The immediate practical consequence is that `forceCleanRestart` is re-set to `true` on every failed retry cycle at or above the threshold, rather than exactly once per restart decision. As long as the callback stays idempotent this won't cause a functional breakage, but it violates the documented contract and could be surprising if the callback ever gains additional side-effects.

**Suggested fix**: Move the escalation call inside the try block (right before `startChannel`), or update the record on failure:

```typescript
try {
  if (status.running) {
    await channelManager.stopChannel(channelId as ChannelId, accountId);
  }
  channelManager.resetRestartAttempts(channelId as ChannelId, accountId);

  if (nextConsecutive >= ESCALATION_THRESHOLD && onBeforeRestart) {
    onBeforeRestart({ channelId: channelId as ChannelId, accountId, consecutiveRestarts: nextConsecutive });
  }

  await channelManager.startChannel(channelId as ChannelId, accountId);
  record.lastRestartAt = now;
  record.restartsThisHour.push({ at: now });
  record.consecutiveRestarts = nextConsecutive;
  restartRecords.set(key, record);
} catch (err) { … }
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — addressed in commit 5979119a73 via a different approach than suggested but with the same end result.

Rather than moving onBeforeRestart inside the try block, record.consecutiveRestarts is now incremented optimistically before the try block (set to nextConsecutive and persisted via restartRecords.set before the restart attempt). This means:

  • On failure, consecutiveRestarts is already at nextConsecutive, so the next check cycle sees a higher level — the escalation threshold cannot be re-triggered at the same counter value.
  • onBeforeRestart still fires pre-try (intentional: the callback is meant to configure the next start, before startChannel runs), but it only fires once per counter level rather than on every failed retry.

Combined with the catch block now setting record.lastRestartAt = now (from the fix to r2909175303), the cooldown guard is also applied on persistent stopChannel/startChannel failures, so rapid-fire re-invocations of the callback are additionally rate-limited.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2e44b5f + 509174b.

Two-part resolution:

  1. consecutiveRestarts is now incremented optimistically before the try block — so if stopChannel/startChannel throws, the counter is already past the threshold on the next cycle and onBeforeRestart cannot re-fire at the same threshold level (contract preserved).
  2. The catch block now sets record.lastRestartAt = now and persists via restartRecords.set, so the exponential cooldown applies on persistent failure cycles and retries no longer happen every check interval.

50/50 tests passing.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. consecutiveRestarts is now incremented optimistically before the try block (commit 5979119), so if stopChannel/startChannel throws: (1) the escalation counter is already updated — onBeforeRestart will not re-fire at the same threshold level on the next cooldown expiry; (2) the catch block also sets record.lastRestartAt = now (commit 2e44b5f) so the cooldown guard properly applies on persistent failure cycles. The callback contract is now upheld.

Comment on lines +25 to +48
const persistentStates = new Map<string, DiscordLifecyclePersistentState>();

export function getOrCreatePersistentState(accountId: string): DiscordLifecyclePersistentState {
let state = persistentStates.get(accountId);
if (!state) {
state = { consecutiveHelloStalls: 0, forceCleanRestart: false };
persistentStates.set(accountId, state);
}
return state;
}

/** Signal that the next lifecycle start should clear Discord resume state. */
export function requestCleanRestart(accountId: string): void {
getOrCreatePersistentState(accountId).forceCleanRestart = true;
}

/** Reset persistent counters after the channel has been stable. */
export function markStable(accountId: string): void {
const state = persistentStates.get(accountId);
if (state) {
state.consecutiveHelloStalls = 0;
state.forceCleanRestart = false;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module-level persistentStates Map grows for every account that ever calls getOrCreatePersistentState. There is currently no path that removes an entry — not markStable, not an account-deletion event, nothing. For a long-running gateway process where Discord accounts are added and later removed or disabled, this is a slow memory leak.

Consider adding an evictPersistentState(accountId: string) export (analogous to markStable) that the server can call when an account is fully removed from the config, similar to how unregisterGateway cleans up the gateway registry:

export function evictPersistentState(accountId: string): void {
  persistentStates.delete(accountId);
}

This would also make the module easier to test in isolation (tests currently share the same Map instance across describe blocks unless they use unique accountId strings).

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/discord/monitor/provider.lifecycle.ts
Line: 25-48

Comment:
The module-level `persistentStates` Map grows for every account that ever calls `getOrCreatePersistentState`. There is currently no path that removes an entry — not `markStable`, not an account-deletion event, nothing. For a long-running gateway process where Discord accounts are added and later removed or disabled, this is a slow memory leak.

Consider adding an `evictPersistentState(accountId: string)` export (analogous to `markStable`) that the server can call when an account is fully removed from the config, similar to how `unregisterGateway` cleans up the gateway registry:

```typescript
export function evictPersistentState(accountId: string): void {
  persistentStates.delete(accountId);
}
```

This would also make the module easier to test in isolation (tests currently share the same Map instance across describe blocks unless they use unique `accountId` strings).

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — evictPersistentState(accountId: string) export was added to src/discord/monitor/provider.lifecycle.ts alongside markStable. server.impl.ts now calls evictDiscordPersistentState(accountId) for each account that is present in prevDiscordAccountIds but absent from the current config on hot reload, mirroring the unregisterGateway cleanup pattern. The Map entry is also cleared on full Discord removal.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ba3a20d (local: evictPersistentState added to src/discord/monitor/provider.lifecycle.ts).

export function evictPersistentState(accountId: string): void {
  persistentStates.delete(accountId);
}

evictDiscordPersistentState is now called from server.impl.ts on hot reload whenever a Discord account is removed from the config. Also added a test-isolation note: tests should use unique accountId strings per describe block (or call evictPersistentState in afterEach) to avoid state bleed across describe blocks in the shared Map.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5979119. Added evictDiscordPersistentState(accountId: string) export (calls persistentStates.delete(accountId)). It is wired in server.impl.ts during hot reload: when prevDiscordAccountIds contains IDs no longer in the new config, each removed account is evicted. Three scenarios handled: single-account token shape (preserves "default"), multi-account shape (evicts only removed accounts), Discord fully removed (evicts all). Tests cover the eviction path.

@jellyAI-dev

Copy link
Copy Markdown
Author

Fixed in 68abc3e — consecutiveRestarts is now incremented optimistically before the try block. If the restart attempt fails, backoff still increases on the next cycle and onBeforeRestart does not re-fire at the same threshold level. lastRestartAt remains inside the try block (only updated on a successful restart) to keep cooldown behaviour correct. 32/32 tests passing.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5979119a73

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/gateway/server.impl.ts Outdated
@@ -988,6 +1033,15 @@ export async function startGatewayServer(
}
throw err;
}
// Evict persistent state for discord accounts no longer in the new config.
if (prevDiscordAccountIds.length > 0) {
const nextDiscordAccountIds = new Set(listDiscordAccountIds(prepared.config));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive eviction set from explicit Discord accounts

nextDiscordAccountIds is built with listDiscordAccountIds, which falls back to an implicit "default" account when no Discord accounts are configured. On hot reloads that remove Discord (or remove the default account), this keeps default in the set and prevents evictDiscordPersistentState("default"), so stale consecutiveHelloStalls/forceCleanRestart state survives account removal and can affect a later re-add of that account.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2e44b5f.

listDiscordAccountIds is replaced by the new listConfiguredDiscordAccountIds export (backed by listConfiguredAccountIds from the account-helpers factory) in both the server.impl.ts eviction path and the import. listConfiguredAccountIds returns an empty array when no Discord accounts are configured rather than falling back to ["default"], so removing Discord entirely now correctly evicts all previous account entries.

Comment on lines +233 to +234
record.consecutiveRestarts = nextConsecutive;
restartRecords.set(key, record);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply cooldown bookkeeping when restart attempts fail

This increments consecutiveRestarts before the restart attempt, but failed attempts do not update lastRestartAt; the cooldown check is based on now - record.lastRestartAt, so when stopChannel/startChannel throws, retries can still happen every check interval instead of backing off. In persistent failure scenarios, that defeats the new exponential cooldown behavior and can repeatedly trigger escalation callbacks.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2e44b5f.

The catch block now sets record.lastRestartAt = now (and persists via restartRecords.set) on failure, so the exponential backoff applies on persistent failure cycles. consecutiveRestarts was already incremented optimistically before the try block, so the backoff exponent was correct — this closes the remaining gap where the cooldown guard (now - record.lastRestartAt <= effectiveCooldownMs) could be bypassed on repeated stopChannel/startChannel failures.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2e44b5fb8a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1043 to +1045
const nextDiscordAccountIds = new Set(
listConfiguredDiscordAccountIds(prepared.config),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve implicit default account during Discord eviction

This cleanup computes nextDiscordAccountIds via listConfiguredDiscordAccountIds(...), but that helper returns only explicit channels.discord.accounts.* keys (empty when using the supported single-account channels.discord.token shape from DiscordConfig). In that common configuration, a Discord hot reload will see the running default account in prevDiscordAccountIds but not in nextDiscordAccountIds, so it evicts persistent state for an account that still exists, resetting consecutiveHelloStalls/forceCleanRestart and weakening the new restart-escalation behavior after every reload.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 368bf82a4.

listConfiguredDiscordAccountIds only knows about explicit channels.discord.accounts.* keys, so the single-account channels.discord.token shape produced an empty set — causing "default" to be evicted on every hot reload.

Fix: check whether Discord is actually configured (channels.discord.token exists or listConfiguredDiscordAccountIds returns any IDs). When configured, use listDiscordAccountIds, which correctly resolves "default" for the token shape. When Discord is fully removed, fall back to an empty set so all previous entries are properly evicted.

All three scenarios now handled correctly:

  • Single-account token shape → "default" preserved, no spurious eviction ✅
  • Multi-account shape → only removed accounts evicted ✅
  • Discord fully removed → all previous accounts evicted ✅

50/50 tests passing.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 368bf82a4b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/gateway/server.impl.ts Outdated
Comment on lines +1050 to +1052
const hasDiscordConfig = !!(
prepared.config.channels?.discord?.token ||
listConfiguredDiscordAccountIds(prepared.config).length > 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include env-backed default account in Discord eviction set

hasDiscordConfig only checks prepared.config.channels?.discord?.token and explicit accounts.* keys, so it evaluates false when Discord is configured via DISCORD_BOT_TOKEN only. In that supported setup (resolveDiscordToken falls back to process.env.DISCORD_BOT_TOKEN for default), hot reload computes an empty nextDiscordAccountIds set and evicts default even though the account is still active, which resets consecutiveHelloStalls/forceCleanRestart and weakens the new restart-escalation flow after each reload.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added || !!process.env.DISCORD_BOT_TOKEN to hasDiscordConfig so the env-backed default account is retained in the eviction set. All four scenarios now handled:

  • channels.discord.token in config → ✅ default preserved
  • Explicit channels.discord.accounts.* → ✅ only removed accounts evicted
  • DISCORD_BOT_TOKEN env-var only → ✅ default preserved, no spurious eviction
  • Discord fully removed (no config, no env) → ✅ all previous accounts evicted

46/46 tests passing (channel-health-monitor + channel-health-policy).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e7c8a7e — added || process.env.DISCORD_BOT_TOKEN to the hasDiscordConfig guard in server.impl.ts. This covers the env-var-only path (resolveDiscordToken falls back to process.env.DISCORD_BOT_TOKEN for the implicit "default" account) so that "default" is correctly preserved in nextDiscordAccountIds during hot reloads when Discord is configured solely via the environment variable, preventing spurious eviction of consecutiveHelloStalls/forceCleanRestart state. All three configurations now handled correctly: token in config, explicit accounts, or env-var only.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added process.env.DISCORD_BOT_TOKEN to the hasDiscordConfig guard. When Discord is configured via env-only, hot reloads now correctly treat the default account as active and skip eviction. All tests passing.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dbb2934.

The listConfiguredDiscordAccountIds-only approach had a gap: it returns [] for the single-account channels.discord.token shape, causing "default" to be spuriously evicted on every hot reload.

Fix applied in server.impl.ts:

const configuredIds = listConfiguredDiscordAccountIds(prepared.config);
const hasDiscordConfig = !!(
  (configuredIds.length > 0) ||
  prepared.config.channels?.discord?.token ||
  process.env.DISCORD_BOT_TOKEN
);
// When configured, preserve "default" for single-account token shape
const nextDiscordAccountIds = new Set(
  hasDiscordConfig ? (configuredIds.length > 0 ? configuredIds : ["default"]) : [],
);

All three scenarios now handled correctly:

  • Single-account token shape → "default" preserved, no spurious eviction ✅
  • DISCORD_BOT_TOKEN env-var-only path → "default" preserved ✅
  • Multi-account shape → only removed accounts evicted ✅
  • Discord fully removed → all previous accounts evicted ✅

50/50 tests passing.

@jellyAI-dev
jellyAI-dev force-pushed the fix/discord-health-restart-loop branch from e7c8a7e to 2f6b00e Compare March 14, 2026 13:54

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2f6b00e561

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/gateway/server.impl.ts Outdated
Comment on lines +1038 to +1039
const prevDiscordAccountIds = plan.restartChannels.has("discord")
? Object.keys(getRuntimeSnapshot().channelAccounts["discord"] ?? {})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Snapshot old Discord accounts before activating new config

prevDiscordAccountIds is computed from getRuntimeSnapshot() after activateRuntimeSecrets(nextConfig, { activate: true }), but getRuntimeSnapshot() derives account IDs from loadConfig() (see src/gateway/server-channels.ts), so this already reflects the new config rather than the pre-reload runtime. In an account-removal hot reload, removed IDs are absent from prevDiscordAccountIds, so evictDiscordPersistentState is never called for them and stale consecutiveHelloStalls/forceCleanRestart state can leak into a later re-add of that account.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — moved the prevDiscordAccountIds snapshot to before activateRuntimeSecrets(...) is called, so it captures the pre-reload account list. Removed accounts are now correctly included in the eviction set and evictDiscordPersistentState fires for them on hot reload. Also fixed the broken import paths (../discord/accounts.js../../extensions/discord/src/accounts.js) and added a regression test in server.reload.test.ts verifying eviction fires for all removed accounts. Commit: e74e4cbf0.

vashquez and others added 8 commits March 16, 2026 21:12
…rts (openclaw#38596)

Break the infinite restart loop where health-monitor restarts reset the
consecutiveHelloStalls counter. Changes:

- Move consecutiveHelloStalls into module-level persistent state keyed by
  accountId so it accumulates across health-monitor restart cycles
- Add forceCleanRestart flag: after N consecutive restarts without
  stability, the health monitor signals Discord to clear resume state
  and perform a fresh IDENTIFY on next lifecycle start
- Track consecutive restart count per channel in the health monitor;
  reset when the channel stays healthy for >5 min
- Apply exponential backoff to health-monitor cooldown (base × 2^n,
  capped at 60 min) so repeated failures don't spam restarts
- Wire up onBeforeRestart / onChannelStable callbacks in server.impl.ts
  for Discord-specific escalation
- Add tests for persistent state, escalation, backoff, and stability
The module-level persistentStates Map was never pruned when Discord
accounts were removed from config, causing a slow memory leak in
long-running gateway processes.

- Add evictPersistentState(accountId) to provider.lifecycle.ts,
  analogous to markStable but removes the entry entirely
- Wire eviction in server.impl.ts onHotReload: snapshot prevDiscordAccountIds
  before applyHotReload, then evict any IDs absent from the new config

Addresses greptile-apps[bot] review comment:
openclaw#40413 (comment)

50/50 tests passing (18 lifecycle + 32 health-monitor).
…configured

listConfiguredDiscordAccountIds returns [] for the single-account
channels.discord.token shape, causing the implicit 'default' account
to be evicted from persistentStates on every hot reload and resetting
consecutiveHelloStalls / forceCleanRestart unnecessarily.

Fix: check whether Discord is actually configured (top-level token or
explicit accounts) before building nextDiscordAccountIds. When configured,
use listDiscordAccountIds which correctly resolves 'default' for the
single-account token shape. When Discord is fully removed, fall back to
an empty set so all previous accounts are properly evicted.

Addresses: openclaw#40413 (comment)
@jellyAI-dev
jellyAI-dev force-pushed the fix/discord-health-restart-loop branch from e74e4cb to 6cefcfa Compare March 16, 2026 13:12
@jellyAI-dev

Copy link
Copy Markdown
Author

Rebased onto current origin/main (2026-03-16).

Conflicts resolved in src/gateway/server.impl.ts:

Tests after rebase:

  • channel-health-monitor.test.ts — 34/34 ✅
  • provider.lifecycle.test.ts — all pass ✅
  • server.reload.test.ts — 12/13 (1 pre-existing failure on origin/main, unrelated to this PR)

New HEAD: 6cefcfa2f3aa650495f4628076c5e19d06108c22

@openclaw-barnacle

Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added the stale Marked as stale due to inactivity label Apr 25, 2026
@vincentkoc

Copy link
Copy Markdown
Member

ProjectClownfish could not safely update this branch, so it opened a narrow replacement PR instead.

Replacement PR: #72309
Source PR: #40413
Contributor credit is preserved in the replacement PR body and changelog plan.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: discord Channel integration: discord gateway Gateway runtime size: L stale Marked as stale due to inactivity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Discord: health monitor restart loop — post-connection zombie sessions evade circuit breakers

3 participants