Skip to content

Commit 4c9bd2d

Browse files
authored
fix(controller): stop masquerading gateway-WS failure as per-channel credential failure (#1128)
Issue #856: after 0.1.10-nightly.20260406 users reported all IM channels showing as disconnected with a "Reconnect required" CTA, forcing them to re-authenticate channels whose tokens on disk were still valid. Root cause: openclaw-gateway-service.ts#getAllChannelsLiveStatus had two fallback paths (WS-not-connected pre-check and RPC-throws catch) that both returned status:"disconnected", configured:false, lastError:null for every channel. The web UI treats that shape as a real credential failure and prompts re-auth. When the OpenClaw gateway WS was transiently unreachable (skillhub sync churn, deterministic-config reload, langfuse restart, OAuth apiKey rejection) every channel got flagged disconnected despite nothing being wrong. Channel records on disk were never touched — only the live-status reply lied. Fix: both fallback paths now return status:"connecting", configured:true, lastError:null. The existing UI path for "connecting" (home.tsx:273-327 getChannelStatusMeta) already renders the amber spinner without a re-auth CTA, and the existing "Offline" + "Agent starting…" header pills already communicate the gateway-unavailable state. No new banner or i18n strings were needed. Supporting cleanup: runtimeState / isBootPhasePreReady threading was only used by the branch we just simplified, so the service constructor drops that parameter. container.ts and the two test fixtures (openclaw-gateway-service.test.ts, route-compat.test.ts) updated for the new signature. Tests: two new cases in openclaw-gateway-service.test.ts cover the WS-not-connected and RPC-throws branches and assert the honest shape. Packaged smoke test (macOS arm64, unsigned): baseline green, kill openclaw via launchctl bootout + kill -9, UI flips to red Offline / amber Agent-starting / amber Feishu-connecting with NO red shield and NO Reconnect CTA; restart openclaw, UI returns to green within one poll cycle with no re-auth required. Full write-up at specs/design-docs/2026-04-15-channel-status-masquerade-fix.md including a sidebar on launchd KeepAlive supervision (OpenClaw auto-restarts within ~5s of a crash as long as the controller is alive).
1 parent 6d7943f commit 4c9bd2d

5 files changed

Lines changed: 179 additions & 34 deletions

File tree

apps/controller/src/app/container.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export async function createContainer(): Promise<ControllerContainer> {
9999
const openclawProcess = new OpenClawProcessManager(env);
100100
const watchTrigger = new OpenClawWatchTrigger(env, openclawProcess);
101101
const wsClient = new OpenClawWsClient(env);
102-
const gatewayService = new OpenClawGatewayService(wsClient, runtimeState);
102+
const gatewayService = new OpenClawGatewayService(wsClient);
103103
const controlPlaneHealth = new ControlPlaneHealthService(
104104
gatewayService,
105105
wsClient,

apps/controller/src/services/openclaw-gateway-service.ts

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,6 @@ import type { OpenClawConfig } from "@nexu/shared";
1414
import { logger } from "../lib/logger.js";
1515
import { serializeOpenClawConfig } from "../lib/openclaw-config-serialization.js";
1616
import type { OpenClawWsClient } from "../runtime/openclaw-ws-client.js";
17-
import {
18-
type ControllerRuntimeState,
19-
isBootPhasePreReady,
20-
} from "../runtime/state.js";
2117

2218
// ---------------------------------------------------------------------------
2319
// Public types — channel status & readiness
@@ -148,10 +144,7 @@ export class OpenClawGatewayService {
148144
/** SHA-256 hash of the last config we successfully observed. */
149145
private lastPushedConfigHash: string | null = null;
150146

151-
constructor(
152-
private readonly wsClient: OpenClawWsClient,
153-
private readonly runtimeState: ControllerRuntimeState,
154-
) {}
147+
constructor(private readonly wsClient: OpenClawWsClient) {}
155148

156149
/** Whether the WS client has completed handshake and is ready for RPC. */
157150
isConnected(): boolean {
@@ -327,24 +320,22 @@ export class OpenClawGatewayService {
327320
channels: ChannelLiveStatusEntry[];
328321
}> {
329322
if (!this.wsClient.isConnected()) {
330-
// During boot or when gateway is still starting, show "connecting"
331-
// instead of "disconnected" so the UI doesn't flash a scary red state.
332-
const startupStatus: ChannelLiveStatus =
333-
isBootPhasePreReady(this.runtimeState.bootPhase) ||
334-
this.runtimeState.gatewayStatus === "starting"
335-
? "connecting"
336-
: "disconnected";
323+
// WS is not connected: we cannot observe live channel status. Report
324+
// "connecting" regardless of boot phase so the UI surfaces a neutral
325+
// gateway-offline state instead of a per-channel credential failure,
326+
// and preserve configured: true for channels with persisted credentials
327+
// so the UI does not render the "not configured" reconnect prompt.
337328
return {
338329
gatewayConnected: false,
339330
channels: channels.map((channel) => ({
340331
channelType: channel.channelType,
341332
channelId: channel.id,
342333
accountId: channel.accountId,
343-
status: startupStatus,
334+
status: "connecting",
344335
ready: false,
345336
connected: false,
346337
running: false,
347-
configured: false,
338+
configured: true,
348339
lastError: null,
349340
})),
350341
};
@@ -492,17 +483,21 @@ export class OpenClawGatewayService {
492483
{ error: err instanceof Error ? err.message : String(err) },
493484
"openclaw_channels_live_status_error",
494485
);
486+
// Gateway RPC failed mid-flight — treat as a transient gateway outage.
487+
// Report "connecting" + configured: true so the UI shows the
488+
// gateway-offline banner instead of prompting users to re-authenticate
489+
// channels whose credentials on disk are still valid.
495490
return {
496491
gatewayConnected: false,
497492
channels: channels.map((channel) => ({
498493
channelType: channel.channelType,
499494
channelId: channel.id,
500495
accountId: channel.accountId,
501-
status: "disconnected",
496+
status: "connecting",
502497
ready: false,
503498
connected: false,
504499
running: false,
505-
configured: false,
500+
configured: true,
506501
lastError: null,
507502
})),
508503
};

apps/controller/tests/openclaw-gateway-service.test.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { OpenClawConfig } from "@nexu/shared";
2-
import { describe, expect, it } from "vitest";
2+
import { describe, expect, it, vi } from "vitest";
33
import { OpenClawGatewayService } from "../src/services/openclaw-gateway-service.js";
44

55
function makeConfig(overrides: Partial<OpenClawConfig> = {}): OpenClawConfig {
@@ -17,12 +17,9 @@ function makeConfig(overrides: Partial<OpenClawConfig> = {}): OpenClawConfig {
1717

1818
describe("OpenClawGatewayService", () => {
1919
it("treats semantically identical configs as unchanged despite key reorder", async () => {
20-
const service = new OpenClawGatewayService(
21-
{
22-
isConnected: () => true,
23-
} as never,
24-
{} as never,
25-
);
20+
const service = new OpenClawGatewayService({
21+
isConnected: () => true,
22+
} as never);
2623

2724
const configA = makeConfig({
2825
plugins: {
@@ -47,4 +44,49 @@ describe("OpenClawGatewayService", () => {
4744

4845
await expect(service.shouldPushConfig(configB)).resolves.toBe(false);
4946
});
47+
48+
describe("getAllChannelsLiveStatus gateway-offline reporting", () => {
49+
const channels = [
50+
{ id: "ch1", channelType: "feishu", accountId: "feishu-acct" },
51+
{ id: "ch2", channelType: "slack", accountId: "T0001" },
52+
];
53+
54+
it("reports connecting + configured when WS is not connected", async () => {
55+
const service = new OpenClawGatewayService({
56+
isConnected: () => false,
57+
request: vi.fn(),
58+
} as never);
59+
60+
const result = await service.getAllChannelsLiveStatus(channels);
61+
62+
expect(result.gatewayConnected).toBe(false);
63+
for (const entry of result.channels) {
64+
expect(entry.status).toBe("connecting");
65+
expect(entry.configured).toBe(true);
66+
expect(entry.connected).toBe(false);
67+
expect(entry.running).toBe(false);
68+
expect(entry.lastError).toBeNull();
69+
}
70+
});
71+
72+
it("reports connecting + configured when the channels.status RPC throws", async () => {
73+
const service = new OpenClawGatewayService({
74+
isConnected: () => true,
75+
request: vi.fn(async () => {
76+
throw new Error("openclaw gateway not connected");
77+
}),
78+
} as never);
79+
80+
const result = await service.getAllChannelsLiveStatus(channels);
81+
82+
expect(result.gatewayConnected).toBe(false);
83+
for (const entry of result.channels) {
84+
expect(entry.status).toBe("connecting");
85+
expect(entry.configured).toBe(true);
86+
expect(entry.connected).toBe(false);
87+
expect(entry.running).toBe(false);
88+
expect(entry.lastError).toBeNull();
89+
}
90+
});
91+
});
5092
});

apps/controller/tests/route-compat.test.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,10 @@ async function createTestContainer(
113113
isConnected: () => false,
114114
stop: vi.fn(),
115115
} as unknown as ControllerContainer["wsClient"];
116-
const gatewayService = new OpenClawGatewayService(
117-
{
118-
isConnected: () => false,
119-
request: vi.fn(),
120-
} as never,
121-
runtimeState,
122-
);
116+
const gatewayService = new OpenClawGatewayService({
117+
isConnected: () => false,
118+
request: vi.fn(),
119+
} as never);
123120
const controlPlaneHealth = new ControlPlaneHealthService(
124121
gatewayService,
125122
wsClient,
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Channel-status masquerade on OpenClaw gateway WS failure
2+
3+
Date: 2026-04-15
4+
Related issue: [#856](https://github.com/…/issues/856) — "All channel connection states are reset in version 0.1.10-nightly.20260406"
5+
6+
## Symptom
7+
8+
After an update to `0.1.10-nightly.20260406`, users reported every IM channel (Feishu, DingTalk, WeCom, …) showing as disconnected with a "Reconnect required" CTA, forcing them to re-authenticate channels whose tokens on disk were still valid. Reported as "all channel connection states were reset."
9+
10+
## Root cause
11+
12+
Not data loss. The controller's `openclaw-gateway-service.ts:getAllChannelsLiveStatus` has two fallback paths — a pre-check when the WebSocket is not connected, and a catch block when the RPC throws mid-flight. Both paths were returning:
13+
14+
```ts
15+
{
16+
gatewayConnected: false,
17+
channels: channels.map((channel) => ({
18+
…,
19+
status: "disconnected",
20+
configured: false,
21+
lastError: null,
22+
})),
23+
}
24+
```
25+
26+
The web UI (home.tsx, channels.tsx) treats `status: "disconnected" + configured: false` as a credential failure — same as a revoked token — and renders the red "Reconnect required" affordance. When the OpenClaw gateway WS was transiently unreachable (gateway restart loop from the Apr 3–6 nightly, skillhub sync churn, langfuse reload, etc.), every channel reported disconnected and every user was prompted to re-auth, despite nothing being wrong with their credentials.
27+
28+
The channel records on disk (`~/.nexu/config.json`) were never touched — only the live-status reply lied.
29+
30+
## Fix
31+
32+
`apps/controller/src/services/openclaw-gateway-service.ts`: in both the WS-not-connected branch and the catch branch, report the degraded state honestly:
33+
34+
```ts
35+
{
36+
gatewayConnected: false,
37+
channels: channels.map((channel) => ({
38+
…,
39+
status: "connecting", // we cannot yet observe live state
40+
configured: true, // credentials on disk are still valid
41+
lastError: null,
42+
})),
43+
}
44+
```
45+
46+
The web UI already renders `"connecting"` as an amber spinner with no re-auth CTA (see `apps/web/src/pages/home.tsx:273-327` `getChannelStatusMeta`). The existing top-level "Offline" pill (driven by `/api/internal/desktop/ready`) and the "Agent starting…" pill (driven by `agent.alive`) already communicate the gateway-unavailable state clearly — no new banner or copy was needed.
47+
48+
Now-unused `runtimeState` / `isBootPhasePreReady` threading was removed from the service; `container.ts` and 2 test fixtures updated for the simplified constructor.
49+
50+
## Why the Apr 10–14 fixes masked the defect
51+
52+
The catch block itself has existed for a long time; it only became visibly problematic in the `0.1.10-nightly.20260406` build because that nightly had several OpenClaw-reload loops that kept flipping the WS connection:
53+
54+
| Commit | Fix |
55+
| --- | --- |
56+
| `7a03c2b0` | batch skillhub sync to prevent OpenClaw restart loop |
57+
| `87ea4cb9` | deterministic openclaw.json serialization (stops key-reorder reloads) |
58+
| `02e73549` | make langfuse-tracer always-allow to avoid gateway restart |
59+
| `bf631409` | stop emitting `apiKey: ""` for OAuth providers (OpenClaw was rejecting the whole models.providers block) |
60+
61+
Those commits stabilized the gateway so the catch block stopped firing in practice, which is why testers stopped seeing the symptom after Apr 14. The latent masquerade stayed on main until this PR: any future WS flap would have reproduced #856 exactly.
62+
63+
## Verification
64+
65+
### Unit
66+
- `apps/controller/tests/openclaw-gateway-service.test.ts`: two new cases covering the WS-not-connected and RPC-throws branches assert `gatewayConnected: false`, `status: "connecting"`, `configured: true`.
67+
- Existing tests (`apps/web/tests/channel-live-status.test.ts`, `apps/controller/tests/route-compat.test.ts`) pass against the simplified service constructor.
68+
- `pnpm typecheck` ✅ • `pnpm lint` ✅ (0 errors from this change) • controller suite: 255 passing / 24 pre-existing failures unchanged.
69+
70+
### Packaged smoke test (macOS arm64, unsigned build)
71+
72+
Baseline: Feishu connected, "Running", "Agent running" (all green). `curl /api/v1/channels/live-status``gatewayConnected: true, agent.alive: true, status: "connected"`.
73+
74+
Kill OpenClaw: `launchctl bootout gui/$(id -u)/io.nexu.openclaw && kill -9 <pid>`
75+
76+
Expected (and observed):
77+
- Header pill flips to red **"Offline"**
78+
- Agent pill flips to amber **"Agent starting…"**
79+
- Feishu row shows amber **"Connecting…"***no* red shield, *no* "Reconnect" CTA
80+
- API: `gatewayConnected: false`, `status: "connecting"`, `configured: true`, `agent.alive: false`
81+
82+
Restart OpenClaw: `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/io.nexu.openclaw.plist`
83+
84+
Expected (and observed):
85+
- Within ~2–3s: all pills return to green, no re-auth required
86+
- API: `gatewayConnected: true`, `status: "connected"`, `agent.alive: true`
87+
88+
## Supervision sidebar: OpenClaw auto-restart
89+
90+
Surfaced while smoke-testing. In the packaged desktop the OpenClaw plist is configured for automatic supervision:
91+
92+
```
93+
KeepAlive.OtherJobEnabled."io.nexu.controller" = true # respawn while controller alive
94+
ThrottleInterval = 5 # min 5s between respawns
95+
RunAtLoad = false
96+
```
97+
98+
A crashed OpenClaw (e.g. OOM, `kill -9`) is automatically respawned by launchd within 5s as long as the controller is alive. Verified live: killed pid 59414, launchd respawned as pid 63386 without any user action, API recovered to healthy within one poll cycle.
99+
100+
Scenarios where OpenClaw can stay down:
101+
1. The controller itself has died (KeepAlive is gated on `io.nexu.controller`).
102+
2. Someone (or an updater) explicitly `launchctl bootout`s the plist — used in teardown, update-install, and smoke tests.
103+
3. The `MAX_CONSECUTIVE_RESTARTS=10` circuit breaker in `daemon-supervisor-restart.test.ts` trips after 10 consecutive failed respawns.
104+
105+
So the "Offline" state we occasionally saw during testing was a direct artifact of the test-only `launchctl bootout`, not a production regression. Normal crashes self-heal without user action.
106+
107+
## Out of scope
108+
109+
- Root-causing future WS flaps. The Apr 10–14 stability fixes removed the known loops; further regressions belong in their own tickets.
110+
- Changes to `ChannelLiveStatus` enum / `channelLiveStatusResponseSchema`. The `"connecting"` value already existed and renders correctly; adding a new `"unknown"` variant would require SDK regeneration and UI work for no additional user benefit.
111+
- `openclaw-ws-client.ts` reconnection policy.

0 commit comments

Comments
 (0)