(channel = 'evomap-hub')
| 142 | } |
| 143 | |
| 144 | async ackDelivered(channel = 'evomap-hub') { |
| 145 | const waitMs = this._hubUnreachableWaitMs(); |
| 146 | if (waitMs > 0) { |
| 147 | return { |
| 148 | acked: 0, |
| 149 | error: 'hub_unreachable_backoff', |
| 150 | hubUnreachable: true, |
| 151 | retryAfterMs: waitMs, |
| 152 | }; |
| 153 | } |
| 154 | |
| 155 | const delivered = this.store.list({ |
| 156 | type: '%', |
| 157 | direction: 'inbound', |
| 158 | status: 'delivered', |
| 159 | limit: 100, |
| 160 | }).filter(m => m.channel === channel); |
| 161 | |
| 162 | if (delivered.length === 0) return { acked: 0 }; |
| 163 | |
| 164 | const endpoint = `${this.hubUrl}/a2a/mailbox/ack`; |
| 165 | |
| 166 | try { |
| 167 | const senderId = this.store.getState('node_id'); |
| 168 | // Round-8 (§21.5): drain the response body so the undici long-poll |
| 169 | // dispatcher pool is not leaked one socket per ack. ackDelivered |
| 170 | // is called every inbound poll cycle (default 1-10s); the |
| 171 | // pre-round-8 code captured no reference to res and never called |
| 172 | // .json()/.text()/body.cancel(), so each ack pinned a socket |
| 173 | // until GC. After a few minutes of activity the strict-pool was |
| 174 | // exhausted and proxy-mode heartbeats hung on next acquire -- |
| 175 | // matches the "alive once then dead" user symptom in proxy mode. |
| 176 | const res = await hubFetch(endpoint, { |
| 177 | method: 'POST', |
| 178 | headers: this.getHeaders(), |
| 179 | body: JSON.stringify({ sender_id: senderId, message_ids: delivered.map(m => m.id) }), |
| 180 | signal: AbortSignal.timeout(10_000), |
| 181 | }); |
| 182 | await throwIfHubUnreachableResponse(res, 'inbound ack'); |
| 183 | this._recordHubReachable(); |
| 184 | if (res.status === 403 || res.status === 401) { |
| 185 | const errText = await readHubResponseText(res).catch(() => 'unknown'); |
| 186 | throw new AuthError(`Hub ${res.status}: ${sanitizeHubResponseForLog(errText)}`, res.status); |
| 187 | } |
| 188 | if (!res.ok) { |
| 189 | const errText = await readHubResponseText(res).catch(() => 'unknown'); |
| 190 | throw new Error(`Hub returned ${res.status}: ${sanitizeHubResponseForLog(errText)}`); |
| 191 | } |
| 192 | await drainHubResponse(res); |
| 193 | return { acked: delivered.length }; |
| 194 | } catch (err) { |
| 195 | if (err instanceof AuthError) throw err; |
| 196 | if (isHubUnreachableError(err)) { |
| 197 | const retryAfterMs = this._recordHubUnreachable(err); |
| 198 | return { |
| 199 | acked: 0, |
| 200 | error: 'hub_unreachable', |
| 201 | hubUnreachable: true, |
no test coverage detected