You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A contributor ("clanker") holds one long-lived WebSocket to the hive hub for
the whole session: it carries the task assignment, the scoped GitHub token,
progress reports, and a 30-second application-level ping/pong in both
directions.
On hosted-kubestellar-hive-ojv6 that socket is not staying up. Over 32
minutes on 2026-08-29 one contributor's connection died and re-established seven times, with connected lifetimes of 76s, 84s, 30s, 991s, 30s, 300s
and 30s. Three of the seven lasted exactly 30 seconds. The relay reconnects
1s later every time and resumes the task, so work does complete β this is not
a stall.
The cause is not established. It is not the application heartbeat: both
the relay (HEARTBEAT_TIMEOUT_MS = 90000) and the hub
(wsHeartbeatTimeout = 90s) require 90 seconds of silence before they close
anything, so neither can produce a 30-second lifetime, and the relay's
distinctive Heartbeat timeout on β¦ β reconnecting line does not appear in
the log at all. That points below the application β the ingress/network path
in front of the hosted spoke β but nothing on either side records enough to
confirm it.
Which is the second half of this report: every hub-side close is
indistinguishable from a network drop, and the relay throws away the only
evidence there is. Both are one-line fixes and both are prerequisites for
diagnosing the first half.
Blast radius: every contributor on a hosted hub. Work still completes, but
each flap books a release cooldown on the in-flight issue, re-mints a GitHub
token, and writes a joined/left pair into the shared public activity feed.
Evidence
GET /api/contribute/activity, filtered to one contributor, 2026-08-29:
11:12:52 joined β 76s
11:14:08 left β
11:15:22 joined β 84s
11:16:46 left β
11:17:18 joined β 30s
11:17:48 left β
11:22:02 joined β 991s
11:38:33 left β
11:38:34 joined β 30s (+1s reconnect = BASE_RECONNECT_DELAY_MS)
11:39:04 left β
11:39:05 joined β 300s
11:44:05 left β
11:44:06 joined β 30s
11:44:36 left β
19 joined and 19 left events for this one contributor in the retained feed.
The +1s gaps are the relay's BASE_RECONNECT_DELAY_MS; the backoff never
grows past 1s because a successful connect resets it
(bin/contributor-relay.sh:2318), so the log line is always Reconnecting in 1000ms.
Matching relay output, one full cycle, repeated verbatim four times:
Connected to wss://hosted-kubestellar-hive-ojv6β¦/api/contribute/ws
Authenticated β¦ as c-ca8187f7480c (tier: contributor)
Hub protocol 1.2; capabilities: token_refresh, β¦
Reconnected while working on kubestellar/hive#5055 β resuming
GitHub token refreshed
Connection to wss://β¦/api/contribute/ws closed. Reconnecting in 1000ms...
Why nobody can tell what closed it
1. The hub never sends a WebSocket Close frame. Every hub-side close is a
bare conn.Close() / c.ws.Close() β the auth timeout
(contribute_ws.go:2766), the auth-failure paths (:2845, :2861, :2867),
the heartbeat loop's timeout and failed-ping branches (:3639, :3647), and
the stale sweep in cleanupLoop (:3929). None calls WriteControl(websocket.CloseMessage, β¦), so the client sees 1006 (abnormal
closure), empty reason β byte-identical to a yanked cable.
Verified live against the hub with an unauthenticated probe:
0.1s open
0.1s msg: {"type":"auth_challenge","seq":1,"nonce":"β¦"}
30.1s msg: {"type":"auth_failed","reason":"Authentication timeout"}
30.1s CLOSE code= 1006 reason= ""
The hub knew exactly why it was hanging up, said so in a JSON message, and then
closed the socket in a way that carries none of it. A JSON auth_failed is at
least readable; the heartbeat and cleanup closes send nothing at all first.
2. The relay discards the close code and reason.
hub.ws.on('close',()=>{console.log(`Connection to ${hub.url} closed. Reconnecting in ${hub.reconnectDelay}ms...`);
(bin/contributor-relay.sh:2553)
ws hands that callback (code, reason). Logging them would immediately
separate "1006, no frame" (network or a bare Close()) from a deliberate
1000/1001/1011 with text β which is the single fact needed to decide whether
this is the ingress or the hub, and it is currently thrown away on every flap.
What each flap costs
The in-flight issue is briefly withdrawn. The disconnect path calls bookReleaseCooldown (contribute_ws.go:2814) so the issue is not instantly
re-admissible β correct as designed (it closes the /contribute sending "dupe" PRsΒ #2356 duplicate-PR race),
but it means a flapping session repeatedly pulls its own issue out of the
ready-work queue.
A GitHub token is re-minted every cycle β GitHub token refreshed appears
in all four logged reconnects, against a wsTokenRefreshPeriod of 50 minutes.
The shared activity feed fills with churn.joined/left are ordinary
activity entries, so one flapping contributor crowds real picked up / completed rows out of the operator's view and out of /api/contribute/activity.
fleet_size samples become a coin flip β the hourly metric reads 1 or 0
depending on which side of a flap the sample lands.
Each dial re-occupies a slot against the hub-wide maxWSConnections = 50.
Suggested order of work
Log (code, reason) in the relay's close handler. One line, no behavior change.
Have hub-side closes send a real Close frame with a code and a reason before
closing β at minimum the heartbeat/cleanup paths, which today say nothing at all.
With 1 and 2 in place, one flap tells you whether the hub closed it (and why)
or something in the ingress path did. Fix that.
Reported from a just contribute-hive claude local session on Linux against hosted-kubestellar-hive-ojv6, 2026-08-29, working #5055.
Summary
the whole session: it carries the task assignment, the scoped GitHub token,
progress reports, and a 30-second application-level ping/pong in both
directions.
hosted-kubestellar-hive-ojv6that socket is not staying up. Over 32minutes on 2026-08-29 one contributor's connection died and re-established
seven times, with connected lifetimes of 76s, 84s, 30s, 991s, 30s, 300s
and 30s. Three of the seven lasted exactly 30 seconds. The relay reconnects
1s later every time and resumes the task, so work does complete β this is not
a stall.
the relay (
HEARTBEAT_TIMEOUT_MS = 90000) and the hub(
wsHeartbeatTimeout = 90s) require 90 seconds of silence before they closeanything, so neither can produce a 30-second lifetime, and the relay's
distinctive
Heartbeat timeout on β¦ β reconnectingline does not appear inthe log at all. That points below the application β the ingress/network path
in front of the hosted spoke β but nothing on either side records enough to
confirm it.
indistinguishable from a network drop, and the relay throws away the only
evidence there is. Both are one-line fixes and both are prerequisites for
diagnosing the first half.
each flap books a release cooldown on the in-flight issue, re-mints a GitHub
token, and writes a
joined/leftpair into the shared public activity feed.Evidence
GET /api/contribute/activity, filtered to one contributor, 2026-08-29:19
joinedand 19leftevents for this one contributor in the retained feed.The
+1sgaps are the relay'sBASE_RECONNECT_DELAY_MS; the backoff nevergrows past 1s because a successful connect resets it
(
bin/contributor-relay.sh:2318), so the log line is alwaysReconnecting in 1000ms.Matching relay output, one full cycle, repeated verbatim four times:
Why nobody can tell what closed it
1. The hub never sends a WebSocket Close frame. Every hub-side close is a
bare
conn.Close()/c.ws.Close()β the auth timeout(
contribute_ws.go:2766), the auth-failure paths (:2845,:2861,:2867),the heartbeat loop's timeout and failed-ping branches (
:3639,:3647), andthe stale sweep in
cleanupLoop(:3929). None callsWriteControl(websocket.CloseMessage, β¦), so the client sees 1006 (abnormalclosure), empty reason β byte-identical to a yanked cable.
Verified live against the hub with an unauthenticated probe:
The hub knew exactly why it was hanging up, said so in a JSON message, and then
closed the socket in a way that carries none of it. A JSON
auth_failedis atleast readable; the heartbeat and cleanup closes send nothing at all first.
2. The relay discards the close code and reason.
(
bin/contributor-relay.sh:2553)wshands that callback(code, reason). Logging them would immediatelyseparate "1006, no frame" (network or a bare
Close()) from a deliberate1000/1001/1011 with text β which is the single fact needed to decide whether
this is the ingress or the hub, and it is currently thrown away on every flap.
What each flap costs
bookReleaseCooldown(contribute_ws.go:2814) so the issue is not instantlyre-admissible β correct as designed (it closes the /contribute sending "dupe" PRsΒ #2356 duplicate-PR race),
but it means a flapping session repeatedly pulls its own issue out of the
ready-work queue.
GitHub token refreshedappearsin all four logged reconnects, against a
wsTokenRefreshPeriodof 50 minutes.joined/leftare ordinaryactivity entries, so one flapping contributor crowds real
picked up/completedrows out of the operator's view and out of/api/contribute/activity.fleet_sizesamples become a coin flip β the hourly metric reads 1 or 0depending on which side of a flap the sample lands.
maxWSConnections = 50.Suggested order of work
(code, reason)in the relay's close handler. One line, no behavior change.closing β at minimum the heartbeat/cleanup paths, which today say nothing at all.
or something in the ingress path did. Fix that.
Reported from a
just contribute-hive claude localsession on Linux againsthosted-kubestellar-hive-ojv6, 2026-08-29, working#5055.