Merge pull request #572 from ngutman/fix/mac-node-bridge-ping

fix(macos): add node bridge ping loop (AI-assisted)
This commit is contained in:
Peter Steinberger
2026-01-09 13:03:03 +00:00
committed by GitHub
9 changed files with 128 additions and 31 deletions

View File

@@ -2,6 +2,7 @@
## Unreleased
- macOS: add node bridge heartbeat pings to detect half-open sockets and reconnect cleanly. (#572) — thanks @ngutman
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
- Commands: accept /models as an alias for /model.

View File

@@ -23,6 +23,9 @@ actor MacNodeBridgeSession {
private var buffer = Data()
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
private var pingTask: Task<Void, Never>?
private var lastPongAt: Date?
private var lastPingId: String?
private(set) var state: State = .idle
@@ -77,6 +80,7 @@ actor MacNodeBridgeSession {
if base.type == "hello-ok" {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName)
self.startPingLoop()
await onConnected?(ok.serverName)
} else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
@@ -113,6 +117,10 @@ actor MacNodeBridgeSession {
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
try await self.send(BridgePong(type: "pong", id: ping.id))
case "pong":
let pong = try self.decoder.decode(BridgePong.self, from: nextData)
self.notePong(pong)
case "invoke":
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
let res = await onInvoke(req)
@@ -182,6 +190,11 @@ actor MacNodeBridgeSession {
}
func disconnect() async {
self.pingTask?.cancel()
self.pingTask = nil
self.lastPongAt = nil
self.lastPingId = nil
self.connection?.cancel()
self.connection = nil
self.queue = nil
@@ -239,12 +252,17 @@ actor MacNodeBridgeSession {
}
private func send(_ obj: some Encodable) async throws {
guard let connection = self.connection else {
throw NSError(domain: "Bridge", code: 15, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A)
try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation<Void, Error>) in
self.connection?.send(content: line, completion: .contentProcessed { err in
connection.send(content: line, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
@@ -280,6 +298,51 @@ actor MacNodeBridgeSession {
}
}
private func startPingLoop() {
self.pingTask?.cancel()
self.lastPongAt = Date()
self.pingTask = Task { [weak self] in
guard let self else { return }
await self.runPingLoop()
}
}
private func runPingLoop() async {
let intervalSeconds = 15.0
let timeoutSeconds = 45.0
while !Task.isCancelled {
do {
try await Task.sleep(nanoseconds: UInt64(intervalSeconds * 1_000_000_000))
} catch {
return
}
guard self.connection != nil else { return }
if let last = self.lastPongAt,
Date().timeIntervalSince(last) > timeoutSeconds
{
await self.disconnect()
return
}
let id = UUID().uuidString
self.lastPingId = id
do {
try await self.send(BridgePing(type: "ping", id: id))
} catch {
await self.disconnect()
return
}
}
}
private func notePong(_ pong: BridgePong) {
_ = pong
self.lastPongAt = Date()
}
private static func makeStateStream(
for connection: NWConnection) -> AsyncStream<NWConnection.State>
{

View File

@@ -0,0 +1,19 @@
import Testing
@testable import Clawdbot
@Suite
struct MacNodeBridgeSessionTests {
@Test func sendEventThrowsWhenNotConnected() async {
let session = MacNodeBridgeSession()
do {
try await session.sendEvent(event: "test", payloadJSON: "{}")
Issue.record("Expected sendEvent to throw when disconnected")
} catch {
let ns = error as NSError
#expect(ns.domain == "Bridge")
#expect(ns.code == 15)
}
}
}

View File

@@ -179,14 +179,23 @@ async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
mode: "local",
bind: "loopback",
},
agent: {
workspace,
skipBootstrap: true,
},
identity: {
name: DEV_IDENTITY_NAME,
theme: DEV_IDENTITY_THEME,
emoji: DEV_IDENTITY_EMOJI,
agents: {
defaults: {
workspace,
skipBootstrap: true,
},
list: [
{
id: "dev",
default: true,
workspace,
identity: {
name: DEV_IDENTITY_NAME,
theme: DEV_IDENTITY_THEME,
emoji: DEV_IDENTITY_EMOJI,
},
},
],
},
});
await ensureDevWorkspace(workspace);

View File

@@ -278,7 +278,7 @@ describe("doctor", () => {
changes: ["Moved routing.allowFrom → whatsapp.allowFrom."],
});
await doctorCommand(runtime);
await doctorCommand(runtime, { nonInteractive: true });
expect(writeConfigFile).toHaveBeenCalledTimes(1);
const written = writeConfigFile.mock.calls[0]?.[0] as Record<

View File

@@ -114,10 +114,13 @@ export async function doctorCommand(
.join("\n"),
"Legacy config keys detected",
);
const migrate = await prompter.confirm({
message: "Migrate legacy config entries now?",
initialValue: true,
});
const migrate =
options.nonInteractive === true
? true
: await prompter.confirm({
message: "Migrate legacy config entries now?",
initialValue: true,
});
if (migrate) {
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
const { config: migrated, changes } = migrateLegacyConfig(

View File

@@ -170,7 +170,7 @@ export function applyMinimaxHostedProviderConfig(
cfg: ClawdbotConfig,
params?: { baseUrl?: string },
): ClawdbotConfig {
const models = { ...cfg.agent?.models };
const models = { ...cfg.agents?.defaults?.models };
models[MINIMAX_HOSTED_MODEL_REF] = {
...models[MINIMAX_HOSTED_MODEL_REF],
alias: models[MINIMAX_HOSTED_MODEL_REF]?.alias ?? "Minimax",
@@ -206,9 +206,12 @@ export function applyMinimaxHostedProviderConfig(
return {
...cfg,
agent: {
...cfg.agent,
models,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
models: {
mode: cfg.models?.mode ?? "merge",
@@ -248,17 +251,14 @@ export function applyMinimaxHostedConfig(
const next = applyMinimaxHostedProviderConfig(cfg, params);
return {
...next,
agent: {
...next.agent,
model: {
...(next.agent?.model &&
"fallbacks" in (next.agent.model as Record<string, unknown>)
? {
fallbacks: (next.agent.model as { fallbacks?: string[] })
.fallbacks,
}
: undefined),
primary: MINIMAX_HOSTED_MODEL_REF,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(next.agents?.defaults?.model ?? {}),
primary: MINIMAX_HOSTED_MODEL_REF,
},
},
},
};

View File

@@ -1211,6 +1211,7 @@ export type AgentDefaultsConfig = {
| "slack"
| "signal"
| "imessage"
| "msteams"
| "none";
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
to?: string;

View File

@@ -56,8 +56,9 @@ export async function monitorMSTeamsProvider(
const textLimit = resolveTextChunkLimit(cfg, "msteams");
const MB = 1024 * 1024;
const mediaMaxBytes =
typeof cfg.agent?.mediaMaxMb === "number" && cfg.agent.mediaMaxMb > 0
? Math.floor(cfg.agent.mediaMaxMb * MB)
typeof cfg.agents?.defaults?.mediaMaxMb === "number" &&
cfg.agents.defaults.mediaMaxMb > 0
? Math.floor(cfg.agents.defaults.mediaMaxMb * MB)
: 8 * MB;
const conversationStore =
opts.conversationStore ?? createMSTeamsConversationStoreFs();