fix: harden heartbeat acks + gateway reconnect

This commit is contained in:
Peter Steinberger
2025-12-27 20:02:27 +00:00
parent 3a485a14a4
commit 91c9859000
7 changed files with 59 additions and 23 deletions

View File

@@ -4,6 +4,8 @@
### Fixes ### Fixes
- Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors. - Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.
- Heartbeat replies now drop any output containing `HEARTBEAT_OK`, preventing stray emoji/text from being delivered.
- macOS menu now refreshes the control channel after the gateway starts and shows “Connecting to gateway…” while the gateway is coming up.
## 2.0.0-beta3 — 2025-12-27 ## 2.0.0-beta3 — 2025-12-27

View File

@@ -182,6 +182,7 @@ final class GatewayProcessManager {
self.existingGatewayDetails = details self.existingGatewayDetails = details
self.status = .attachedExisting(details: details) self.status = .attachedExisting(details: details)
self.appendLog("[gateway] using existing instance: \(details)\n") self.appendLog("[gateway] using existing instance: \(details)\n")
self.refreshControlChannelIfNeeded(reason: "attach existing")
self.refreshLog() self.refreshLog()
return true return true
} catch { } catch {
@@ -289,6 +290,7 @@ final class GatewayProcessManager {
let instance = await PortGuardian.shared.describe(port: port) let instance = await PortGuardian.shared.describe(port: port)
let details = instance.map { "pid \($0.pid)" } let details = instance.map { "pid \($0.pid)" }
self.status = .running(details: details) self.status = .running(details: details)
self.refreshControlChannelIfNeeded(reason: "gateway started")
self.refreshLog() self.refreshLog()
return return
} catch { } catch {
@@ -307,6 +309,17 @@ final class GatewayProcessManager {
} }
} }
private func refreshControlChannelIfNeeded(reason: String) {
switch ControlChannel.shared.state {
case .connected, .connecting:
return
case .disconnected, .degraded:
break
}
self.appendLog("[gateway] refreshing control channel (\(reason))\n")
Task { await ControlChannel.shared.configure() }
}
func clearLog() { func clearLog() {
self.log = "" self.log = ""
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath) try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)

View File

@@ -110,7 +110,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard self.isControlChannelConnected else { guard self.isControlChannelConnected else {
menu.insertItem(self.makeMessageItem( menu.insertItem(self.makeMessageItem(
text: "No connection to gateway", text: self.controlChannelStatusText,
symbolName: "wifi.slash", symbolName: "wifi.slash",
width: width), at: insertIndex) width: width), at: insertIndex)
return return
@@ -199,7 +199,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard self.isControlChannelConnected else { guard self.isControlChannelConnected else {
menu.insertItem( menu.insertItem(
self.makeMessageItem(text: "No connection to gateway", symbolName: "wifi.slash", width: width), self.makeMessageItem(text: self.controlChannelStatusText, symbolName: "wifi.slash", width: width),
at: cursor) at: cursor)
cursor += 1 cursor += 1
let separator = NSMenuItem.separator() let separator = NSMenuItem.separator()
@@ -259,6 +259,29 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
return false return false
} }
private var controlChannelStatusText: String {
switch ControlChannel.shared.state {
case .connected:
return "Connected"
case .connecting:
return "Connecting to gateway…"
case let .degraded(reason):
if self.shouldShowConnecting { return "Connecting to gateway…" }
return reason.nonEmpty ?? "No connection to gateway"
case .disconnected:
return self.shouldShowConnecting ? "Connecting to gateway…" : "No connection to gateway"
}
}
private var shouldShowConnecting: Bool {
switch GatewayProcessManager.shared.status {
case .starting, .running, .attachedExisting:
return true
case .stopped, .failed:
return false
}
}
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem { private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
let view = AnyView( let view = AnyView(
Label(text, systemImage: symbolName) Label(text, systemImage: symbolName)

View File

@@ -11,6 +11,7 @@ surface anything that needs attention without spamming the user.
## Prompt contract ## Prompt contract
- Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`). - Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`).
- If nothing needs attention, the model must reply **exactly** `HEARTBEAT_OK`. - If nothing needs attention, the model must reply **exactly** `HEARTBEAT_OK`.
- Any response containing `HEARTBEAT_OK` is treated as an ack and discarded.
- For alerts, do **not** include `HEARTBEAT_OK`; return only the alert text. - For alerts, do **not** include `HEARTBEAT_OK`; return only the alert text.
## Config ## Config

View File

@@ -95,6 +95,7 @@ export function buildAgentSystemPromptAppend(params: {
"## Heartbeats", "## Heartbeats",
'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:', 'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:',
"HEARTBEAT_OK", "HEARTBEAT_OK",
'Any response containing "HEARTBEAT_OK" is treated as a heartbeat ack and will not be delivered.',
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.', 'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
"", "",
"## Runtime", "## Runtime",

View File

@@ -19,18 +19,15 @@ describe("stripHeartbeatToken", () => {
}); });
}); });
it("keeps content and removes token when mixed", () => { it("skips any reply that includes the heartbeat token", () => {
expect(stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`)).toEqual({ expect(stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`)).toEqual({
shouldSkip: false, shouldSkip: true,
text: "ALERT", text: "",
}); });
expect(stripHeartbeatToken("hello")).toEqual({ expect(stripHeartbeatToken("HEARTBEAT_OK 🦞")).toEqual({
shouldSkip: false, shouldSkip: true,
text: "hello", text: "",
}); });
});
it("strips repeated OK tails after heartbeat token", () => {
expect(stripHeartbeatToken("HEARTBEAT_OK_OK_OK")).toEqual({ expect(stripHeartbeatToken("HEARTBEAT_OK_OK_OK")).toEqual({
shouldSkip: true, shouldSkip: true,
text: "", text: "",
@@ -48,8 +45,15 @@ describe("stripHeartbeatToken", () => {
text: "", text: "",
}); });
expect(stripHeartbeatToken("ALERT HEARTBEAT_OK_OK")).toEqual({ expect(stripHeartbeatToken("ALERT HEARTBEAT_OK_OK")).toEqual({
shouldSkip: true,
text: "",
});
});
it("keeps non-heartbeat content", () => {
expect(stripHeartbeatToken("hello")).toEqual({
shouldSkip: false, shouldSkip: false,
text: "ALERT", text: "hello",
}); });
}); });
}); });

View File

@@ -6,16 +6,8 @@ export function stripHeartbeatToken(raw?: string) {
if (!raw) return { shouldSkip: true, text: "" }; if (!raw) return { shouldSkip: true, text: "" };
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return { shouldSkip: true, text: "" }; if (!trimmed) return { shouldSkip: true, text: "" };
if (trimmed === HEARTBEAT_TOKEN) return { shouldSkip: true, text: "" }; if (trimmed.includes(HEARTBEAT_TOKEN)) {
const hadToken = trimmed.includes(HEARTBEAT_TOKEN); return { shouldSkip: true, text: "" };
let withoutToken = trimmed.replaceAll(HEARTBEAT_TOKEN, "").trim();
if (hadToken && withoutToken) {
// LLMs sometimes echo malformed HEARTBEAT_OK_OK... tails; strip trailing OK runs to avoid spam.
withoutToken = withoutToken.replace(/[\s_]*OK(?:[\s_]*OK)*$/gi, "").trim();
} }
const shouldSkip = withoutToken.length === 0; return { shouldSkip: false, text: trimmed };
return {
shouldSkip,
text: shouldSkip ? "" : withoutToken || trimmed,
};
} }